mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-31 00:31:28 +01:00
merge: bring in unified-ui-kit-oscvsb for the system dock, kit versions of utils/quick and ux_kit win
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019EaukntuyeZn7cPqVqDTBE
This commit is contained in:
27 files changed
+3391
-268
No files matched your search
@@ -0,0 +1,207 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Layouts
|
||||
import BecUi
|
||||
|
||||
// Every BecUi control in the states a widget uses, laid out like a production panel.
|
||||
Rectangle {
|
||||
id: root
|
||||
color: theme.bg
|
||||
implicitWidth: 760
|
||||
implicitHeight: grid.implicitHeight + 32
|
||||
|
||||
GridLayout {
|
||||
id: grid
|
||||
anchors.fill: parent
|
||||
anchors.margins: 16
|
||||
columns: 2
|
||||
columnSpacing: 12
|
||||
rowSpacing: 12
|
||||
|
||||
Card {
|
||||
title: "Buttons"
|
||||
subtitle: "TextButton · IconButton"
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "Start scan"; variant: "primary"; iconName: "play_arrow" }
|
||||
TextButton { text: "Resume"; variant: "success"; iconName: "play_arrow" }
|
||||
TextButton { text: "Stop"; variant: "danger"; iconName: "stop" }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "Settings"; iconName: "tune" }
|
||||
TextButton { text: "Abort queue"; variant: "dangerOutline"; iconName: "cancel" }
|
||||
TextButton { text: "Details"; variant: "ghost" }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
TextButton { text: "Submitting"; variant: "primary"; busy: true }
|
||||
TextButton { text: "Disabled"; enabled: false }
|
||||
TextButton { text: "Compact"; compact: true; iconName: "refresh" }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 4
|
||||
IconButton { iconName: "pause"; tip: "Pause" }
|
||||
IconButton { iconName: "push_pin"; tip: "Pin"; checkable: true; checked: true }
|
||||
IconButton { iconName: "content_copy"; tip: "Copy" }
|
||||
IconButton { iconName: "delete"; tip: "Remove"; danger: true }
|
||||
IconButton { iconName: "more_vert"; tip: "More"; enabled: false }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Status"
|
||||
subtitle: "StatusPill · Badge · Spinner · LinearProgress"
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
StatusPill { text: "Idle"; tone: "neutral"; outlined: true }
|
||||
StatusPill { text: "Running"; tone: "info"; pulse: true }
|
||||
StatusPill { text: "Done"; tone: "success" }
|
||||
StatusPill { text: "Paused"; tone: "warning" }
|
||||
StatusPill { text: "Failed"; tone: "danger" }
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
StatusPill { text: "Moving"; tone: "info"; iconName: "sync" }
|
||||
StatusPill { text: "Limit"; tone: "warning"; iconName: "warning" }
|
||||
StatusPill { text: "Offline"; tone: "danger"; iconName: "cloud_off" }
|
||||
StatusPill { text: "Stale"; tone: "stale"; iconName: "schedule"; outlined: true }
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
Text { text: "Inbox"; color: theme.fg; font.pixelSize: theme.fontBody }
|
||||
Divider { vertical: true; Layout.preferredHeight: 14; Layout.fillHeight: false }
|
||||
Badge { count: 3 }
|
||||
Badge { count: 128; tone: "warning" }
|
||||
Badge { count: 7; tone: "primary" }
|
||||
Item { Layout.fillWidth: true }
|
||||
Spinner { size: 16 }
|
||||
Text { text: "Connecting"; color: theme.fgMuted; font.pixelSize: theme.fontSmall }
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: 10
|
||||
rowSpacing: 8
|
||||
Text { text: "Scan 42"; color: theme.fgMuted; font.pixelSize: theme.fontSmall }
|
||||
LinearProgress { Layout.fillWidth: true; value: 0.64 }
|
||||
Text { text: "Readout"; color: theme.fgMuted; font.pixelSize: theme.fontSmall }
|
||||
LinearProgress { Layout.fillWidth: true; value: 1; tone: "success" }
|
||||
Text { text: "Beam"; color: theme.fgMuted; font.pixelSize: theme.fontSmall }
|
||||
LinearProgress { Layout.fillWidth: true; value: 0.28; tone: "warning" }
|
||||
Text { text: "Waiting"; color: theme.fgMuted; font.pixelSize: theme.fontSmall }
|
||||
LinearProgress { id: busyBar; Layout.fillWidth: true; indeterminate: true }
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Inputs"
|
||||
subtitle: "FormField · fields · SegmentedControl"
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
SearchField { Layout.fillWidth: true; placeholderText: "Search devices"; shortcutHint: "Ctrl K" }
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
FormField {
|
||||
Layout.fillWidth: true
|
||||
label: "Start"; unit: "mm"; required: true; helper: "Within soft limits"
|
||||
InputField { text: "-5.000"; suffix: "mm" }
|
||||
}
|
||||
FormField {
|
||||
Layout.fillWidth: true
|
||||
label: "Steps"; required: true; error: "Must be at least 2"
|
||||
InputField { text: "1"; invalid: true }
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
spacing: 10
|
||||
FormField {
|
||||
Layout.fillWidth: true
|
||||
label: "Motor"
|
||||
SelectField { model: ["samx", "samy", "samz"] }
|
||||
}
|
||||
FormField {
|
||||
Layout.fillWidth: true
|
||||
label: "Relative"
|
||||
SwitchField { text: checked ? "On" : "Off"; checked: true }
|
||||
}
|
||||
}
|
||||
SegmentedControl {
|
||||
model: [{ text: "All", count: 24 }, { text: "Errors", count: 2 }, { text: "Warnings", count: 5 }]
|
||||
currentIndex: 1
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Feedback"
|
||||
subtitle: "Banner · EmptyState"
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
Banner { Layout.fillWidth: true; tone: "info"; text: "Scan queued behind 2 others." }
|
||||
Banner { Layout.fillWidth: true; tone: "warning"; title: "Beam intensity low"; text: "Ring current is 12 mA below nominal."; closable: true }
|
||||
Banner { Layout.fillWidth: true; tone: "danger"; title: "Device server not responding"; text: "Last heartbeat 40 s ago."; actionText: "Retry" }
|
||||
Banner { Layout.fillWidth: true; tone: "success"; text: "Configuration saved." }
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Cards"
|
||||
subtitle: "titles, collapsible sections"
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
headerExtras: [
|
||||
IconButton { iconName: "refresh"; tip: "Refresh"; compact: true },
|
||||
IconButton { iconName: "more_vert"; tip: "More"; compact: true }
|
||||
]
|
||||
Card {
|
||||
Layout.fillWidth: true
|
||||
title: "Scan arguments"
|
||||
titleStyle: "caption"
|
||||
collapsible: true
|
||||
color: theme.sunken
|
||||
RowLayout {
|
||||
spacing: 8
|
||||
InputField { Layout.fillWidth: true; text: "samx"; iconName: "precision_manufacturing" }
|
||||
InputField { Layout.preferredWidth: 90; text: "0.5"; suffix: "s" }
|
||||
}
|
||||
}
|
||||
Card {
|
||||
Layout.fillWidth: true
|
||||
title: "Metadata"
|
||||
titleStyle: "caption"
|
||||
collapsible: true
|
||||
expanded: false
|
||||
color: theme.sunken
|
||||
}
|
||||
}
|
||||
|
||||
Card {
|
||||
title: "Empty state"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.preferredWidth: 1
|
||||
fillContent: true
|
||||
Item { Layout.fillHeight: true }
|
||||
EmptyState {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
iconName: "playlist_play"
|
||||
title: "Queue is empty"
|
||||
text: "Scans you submit appear here with their progress."
|
||||
actionText: "Open scan control"
|
||||
actionIcon: "add"
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,369 @@
|
||||
"""Gallery of the BEC UI kit: every control in QML and in QWidgets, side by side.
|
||||
|
||||
Run it to check a change to the kit in both technologies and both themes::
|
||||
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery --theme light
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery --screenshots ./shots
|
||||
|
||||
With ``--screenshots`` the gallery renders offscreen into PNG files and exits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from qtpy.QtCore import Qt, QTimer
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.quick import create_quick_widget, release_quick_widget
|
||||
from bec_widgets.utils.quick.tokens import ThemeTokens
|
||||
from bec_widgets.utils.ux_kit import (
|
||||
Badge,
|
||||
Banner,
|
||||
Card,
|
||||
EmptyState,
|
||||
FormField,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
SearchField,
|
||||
SegmentedControl,
|
||||
Spinner,
|
||||
StatusPill,
|
||||
SuffixLineEdit,
|
||||
TextButton,
|
||||
ToggleSwitch,
|
||||
field_qss,
|
||||
)
|
||||
|
||||
QML_GALLERY = Path(__file__).with_name("UiKitGallery.qml")
|
||||
|
||||
|
||||
def _row(*widgets, spacing: int = 8, stretch: bool = True) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.setSpacing(spacing)
|
||||
for widget in widgets:
|
||||
if isinstance(widget, int):
|
||||
layout.addSpacing(widget)
|
||||
else:
|
||||
layout.addWidget(widget)
|
||||
if stretch:
|
||||
layout.addStretch(1)
|
||||
return layout
|
||||
|
||||
|
||||
def _muted(text: str) -> QLabel:
|
||||
tokens = ThemeTokens.current()
|
||||
label = QLabel(text)
|
||||
label.setStyleSheet(
|
||||
f"color: {tokens.fg_muted.name()}; font-size: {tokens.metrics['fontSmall']}px;"
|
||||
" background: none;"
|
||||
)
|
||||
return label
|
||||
|
||||
|
||||
class QWidgetGallery(QWidget):
|
||||
"""The QWidget twin of ``UiKitGallery.qml``."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("uxGallery")
|
||||
tokens = ThemeTokens.current()
|
||||
self.setStyleSheet(f"QWidget#uxGallery {{ background: {tokens.bg.name()}; }}")
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
grid = QGridLayout(self)
|
||||
grid.setContentsMargins(16, 16, 16, 16)
|
||||
grid.setHorizontalSpacing(12)
|
||||
grid.setVerticalSpacing(12)
|
||||
grid.setColumnStretch(0, 1)
|
||||
grid.setColumnStretch(1, 1)
|
||||
cards = [
|
||||
self._buttons(),
|
||||
self._status(),
|
||||
self._inputs(tokens),
|
||||
self._feedback(),
|
||||
self._cards(tokens),
|
||||
self._empty(),
|
||||
]
|
||||
for index, card in enumerate(cards):
|
||||
grid.addWidget(card, index // 2, index % 2, Qt.AlignmentFlag.AlignTop)
|
||||
cards[-1].setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding)
|
||||
grid.addWidget(cards[-1], 2, 1)
|
||||
grid.setRowStretch(3, 1)
|
||||
|
||||
@staticmethod
|
||||
def _buttons() -> Card:
|
||||
card = Card("Buttons")
|
||||
card.set_title("Buttons", "TextButton · IconButton")
|
||||
card.body.addLayout(
|
||||
_row(
|
||||
TextButton("Start scan", "primary", "play_arrow"),
|
||||
TextButton("Resume", "success", "play_arrow"),
|
||||
TextButton("Stop", "danger", "stop"),
|
||||
)
|
||||
)
|
||||
card.body.addLayout(
|
||||
_row(
|
||||
TextButton("Settings", "neutral", "tune"),
|
||||
TextButton("Abort queue", "dangerOutline", "cancel"),
|
||||
TextButton("Details", "ghost"),
|
||||
)
|
||||
)
|
||||
busy = TextButton("Submitting", "primary")
|
||||
busy.set_busy(True)
|
||||
disabled = TextButton("Disabled")
|
||||
disabled.setEnabled(False)
|
||||
card.body.addLayout(
|
||||
_row(busy, disabled, TextButton("Compact", icon_name="refresh", compact=True))
|
||||
)
|
||||
pin = IconButton("push_pin", "Pin")
|
||||
pin.setCheckable(True)
|
||||
pin.setChecked(True)
|
||||
more = IconButton("more_vert", "More")
|
||||
more.setEnabled(False)
|
||||
card.body.addLayout(
|
||||
_row(
|
||||
IconButton("pause", "Pause"),
|
||||
pin,
|
||||
IconButton("content_copy", "Copy"),
|
||||
IconButton("delete", "Remove", danger=True),
|
||||
more,
|
||||
spacing=4,
|
||||
)
|
||||
)
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _status() -> Card:
|
||||
card = Card()
|
||||
card.set_title("Status", "StatusPill · Badge · Spinner · LinearProgress")
|
||||
card.body.addLayout(
|
||||
_row(
|
||||
StatusPill(text="Idle", tone="neutral", outlined=True),
|
||||
StatusPill(text="Running", tone="info", pulse=True),
|
||||
StatusPill(text="Done", tone="success"),
|
||||
StatusPill(text="Paused", tone="warning"),
|
||||
StatusPill(text="Failed", tone="danger"),
|
||||
spacing=6,
|
||||
)
|
||||
)
|
||||
card.body.addLayout(
|
||||
_row(
|
||||
StatusPill(text="Moving", tone="info", icon_name="sync"),
|
||||
StatusPill(text="Limit", tone="warning", icon_name="warning"),
|
||||
StatusPill(text="Offline", tone="danger", icon_name="cloud_off"),
|
||||
StatusPill(text="Stale", tone="stale", icon_name="schedule", outlined=True),
|
||||
spacing=6,
|
||||
)
|
||||
)
|
||||
inbox = QLabel("Inbox")
|
||||
tokens = ThemeTokens.current()
|
||||
inbox.setStyleSheet(f"color: {tokens.fg.name()}; font-size: 13px; background: none;")
|
||||
row = _row(
|
||||
inbox,
|
||||
Badge(count=3),
|
||||
Badge(count=128, tone="warning"),
|
||||
Badge(count=7, tone="primary"),
|
||||
spacing=10,
|
||||
)
|
||||
row.addWidget(Spinner(size=16))
|
||||
row.addWidget(_muted("Connecting"))
|
||||
card.body.addLayout(row)
|
||||
bars = QGridLayout()
|
||||
bars.setHorizontalSpacing(10)
|
||||
bars.setVerticalSpacing(8)
|
||||
for index, (label, value, tone) in enumerate(
|
||||
[("Scan 42", 0.64, "primary"), ("Readout", 1.0, "success"), ("Beam", 0.28, "warning")]
|
||||
):
|
||||
bar = LinearProgress(tone=tone)
|
||||
bar.set_value(value, animate=False)
|
||||
bars.addWidget(_muted(label), index, 0)
|
||||
bars.addWidget(bar, index, 1)
|
||||
waiting = LinearProgress()
|
||||
waiting.set_indeterminate(True)
|
||||
bars.addWidget(_muted("Waiting"), 3, 0)
|
||||
bars.addWidget(waiting, 3, 1)
|
||||
bars.setColumnStretch(1, 1)
|
||||
card.body.addLayout(bars)
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _inputs(tokens: ThemeTokens) -> Card:
|
||||
card = Card()
|
||||
card.set_title("Inputs", "FormField · fields · SegmentedControl")
|
||||
card.setStyleSheet(card.styleSheet() + field_qss(tokens))
|
||||
card.body.addWidget(SearchField(placeholder="Search devices", shortcut_hint="Ctrl K"))
|
||||
start = SuffixLineEdit()
|
||||
start.setText("-5.000")
|
||||
start.set_suffix("mm")
|
||||
steps = SuffixLineEdit()
|
||||
steps.setText("1")
|
||||
steps_field = FormField("Steps", steps, required=True)
|
||||
steps_field.set_error("Must be at least 2")
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
row.addWidget(
|
||||
FormField("Start", start, required=True, helper="Within soft limits", unit="mm"), 1
|
||||
)
|
||||
row.addWidget(steps_field, 1)
|
||||
card.body.addLayout(row)
|
||||
motor = QComboBox()
|
||||
motor.addItems(["samx", "samy", "samz"])
|
||||
switch = ToggleSwitch()
|
||||
switch.setChecked(True)
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(10)
|
||||
row.addWidget(FormField("Motor", motor), 1)
|
||||
row.addWidget(FormField("Relative", switch), 1)
|
||||
card.body.addLayout(row)
|
||||
segmented = SegmentedControl(
|
||||
[
|
||||
{"text": "All", "count": 24},
|
||||
{"text": "Errors", "count": 2},
|
||||
{"text": "Warnings", "count": 5},
|
||||
]
|
||||
)
|
||||
segmented.set_current_index(1)
|
||||
card.body.addLayout(_row(segmented))
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _feedback() -> Card:
|
||||
card = Card()
|
||||
card.set_title("Feedback", "Banner · EmptyState")
|
||||
card.body.addWidget(Banner("info", text="Scan queued behind 2 others."))
|
||||
card.body.addWidget(
|
||||
Banner(
|
||||
"warning",
|
||||
"Beam intensity low",
|
||||
"Ring current is 12 mA below nominal.",
|
||||
closable=True,
|
||||
)
|
||||
)
|
||||
card.body.addWidget(
|
||||
Banner(
|
||||
"danger",
|
||||
"Device server not responding",
|
||||
"Last heartbeat 40 s ago.",
|
||||
action_text="Retry",
|
||||
)
|
||||
)
|
||||
card.body.addWidget(Banner("success", text="Configuration saved."))
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _cards(tokens: ThemeTokens) -> Card:
|
||||
card = Card()
|
||||
card.set_title("Cards", "titles, collapsible sections")
|
||||
card.add_header_widget(IconButton("refresh", "Refresh", compact=True))
|
||||
card.add_header_widget(IconButton("more_vert", "More", compact=True))
|
||||
section = Card("Scan arguments", title_style="caption", collapsible=True, surface="sunken")
|
||||
section.setStyleSheet(section.styleSheet() + field_qss(tokens))
|
||||
motor = SuffixLineEdit()
|
||||
motor.setText("samx")
|
||||
motor.set_icon_name("precision_manufacturing")
|
||||
time = SuffixLineEdit()
|
||||
time.setText("0.5")
|
||||
time.set_suffix("s")
|
||||
time.setFixedWidth(90)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(motor, 1)
|
||||
row.addWidget(time)
|
||||
section.body.addLayout(row)
|
||||
card.body.addWidget(section)
|
||||
closed = Card("Metadata", title_style="caption", collapsible=True, surface="sunken")
|
||||
closed.set_expanded(False)
|
||||
card.body.addWidget(closed)
|
||||
return card
|
||||
|
||||
@staticmethod
|
||||
def _empty() -> Card:
|
||||
card = Card("Empty state")
|
||||
card.body.addStretch(1)
|
||||
card.body.addWidget(
|
||||
EmptyState(
|
||||
"playlist_play",
|
||||
"Queue is empty",
|
||||
"Scans you submit appear here with their progress.",
|
||||
action_text="Open scan control",
|
||||
action_icon="add",
|
||||
)
|
||||
)
|
||||
card.body.addStretch(1)
|
||||
return card
|
||||
|
||||
|
||||
class QmlGallery(QWidget):
|
||||
"""Hosts ``UiKitGallery.qml`` in a QQuickWidget."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.view = create_quick_widget(self, QML_GALLERY, raise_on_error=True)
|
||||
layout.addWidget(self.view)
|
||||
root = self.view.rootObject()
|
||||
self.setMinimumSize(
|
||||
int(root.property("implicitWidth")), int(root.property("implicitHeight"))
|
||||
)
|
||||
|
||||
def closeEvent(self, event): # pylint: disable=invalid-name
|
||||
release_quick_widget(self.view)
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _grab(widget: QWidget, path: Path) -> None:
|
||||
# QWidget.grab() also captures QQuickWidget content, at any QT_SCALE_FACTOR
|
||||
widget.grab().save(str(path))
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Show the gallery, or render screenshots with ``--screenshots DIR``."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--theme", choices=["dark", "light"], default="dark")
|
||||
parser.add_argument("--screenshots", type=Path, default=None)
|
||||
args = parser.parse_args(argv)
|
||||
app = QApplication.instance() or QApplication(sys.argv)
|
||||
apply_theme(args.theme)
|
||||
if args.screenshots is not None:
|
||||
args.screenshots.mkdir(parents=True, exist_ok=True)
|
||||
for theme in ("dark", "light"):
|
||||
apply_theme(theme)
|
||||
for name, cls in (("qml", QmlGallery), ("qwidget", QWidgetGallery)):
|
||||
widget = cls()
|
||||
widget.resize(760, 752)
|
||||
widget.show()
|
||||
loop_until = QTimer()
|
||||
loop_until.setSingleShot(True)
|
||||
loop_until.timeout.connect(app.quit)
|
||||
loop_until.start(700)
|
||||
app.exec()
|
||||
_grab(widget, args.screenshots / f"gallery_{name}_{theme}.png")
|
||||
widget.close()
|
||||
widget.deleteLater()
|
||||
return 0
|
||||
window = QWidget()
|
||||
window.setWindowTitle("BEC UI kit")
|
||||
layout = QHBoxLayout(window)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(QmlGallery())
|
||||
layout.addWidget(QWidgetGallery())
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,135 @@
|
||||
# BEC UI kit
|
||||
|
||||
One set of design tokens and controls for every redesigned BEC widget, in two technologies:
|
||||
|
||||
| Layer | QML | QWidget |
|
||||
| --- | --- | --- |
|
||||
| Tokens | `theme.<token>` context property (`QmlTheme`) | `ThemeTokens.current()` |
|
||||
| Controls | `import BecUi` (`utils/quick/qml/BecUi/`) | `bec_widgets.utils.ux_kit` |
|
||||
| Hosting | `create_quick_widget(...)` (`utils/quick/host.py`) | plain widgets |
|
||||
|
||||
Both read their colours from `app.theme` (`bec_qthemes`) and switch live between light and dark.
|
||||
A widget written with the QML controls and its QWidget twin written with `ux_kit` look the same,
|
||||
so the two versions of each port can be compared side by side.
|
||||
|
||||
See it all at once:
|
||||
|
||||
```bash
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery # QML | QWidget
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery --theme light
|
||||
python -m bec_widgets.examples.ui_kit_gallery.ui_kit_gallery --screenshots ./shots
|
||||
```
|
||||
|
||||
## Tokens
|
||||
|
||||
Defined once in `tokens.py`. Base colours come from the theme XML of `bec_qthemes`; everything
|
||||
else is derived there, so there is exactly one place that decides what "muted text" or "warning
|
||||
tint" means.
|
||||
|
||||
| QML (`theme.`) | Python (`ThemeTokens`) | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `bg` | `bg` | window background (`BG`) |
|
||||
| `card` | `card` | surfaces: cards, popups (`CARD_BG`) |
|
||||
| `sunken` | `sunken` | nested sections and the segmented-control track |
|
||||
| `field` | `field` | input backgrounds (`FIELD_BG`) |
|
||||
| `border`, `separator` | `border`, `separator` | outlines and hairlines |
|
||||
| `fg`, `fgMuted`, `fgSubtle` | `fg`, `fg_muted`, `fg_subtle` | text: primary, secondary, placeholder |
|
||||
| `hover`, `pressed`, `track` | `hover`, `pressed`, `track` | interaction fills, empty progress track |
|
||||
| `primary`, `onPrimary` | `primary`, `on_primary` | main action colour and text on it |
|
||||
| `info`, `success`, `warning`, `danger`, `highlight` | same | status colours (`ACCENT_*`) |
|
||||
| `<tone>Text` | `<tone>_text` | status colour adjusted to stay readable as text on cards |
|
||||
| `<tone>Tint` | `<tone>_tint` | soft status fill for chips and banners |
|
||||
| `dark`, `name` | `dark`, `name` | theme flag and name |
|
||||
| `radiusSmall` 6, `radiusLarge` 10 | `metrics[...]` | controls / cards |
|
||||
| `controlHeight` 32, `controlHeightCompact` 28 | `metrics[...]` | buttons, fields |
|
||||
| `spacing` 8, `padding` 12 | `metrics[...]` | layout rhythm |
|
||||
| `fontCaption` 11, `fontSmall` 12, `fontBody` 13, `fontTitle` 15, `fontHeadline` 20, `fontDisplay` 28 | `metrics[...]` | type scale in px |
|
||||
| `monoFamily` | `mono_family` | fixed-width font for values and logs |
|
||||
|
||||
**Tones.** Status colours are addressed by name everywhere: `neutral`, `subtle`, `primary`,
|
||||
`info`, `success`, `warning`, `danger`, `highlight`. The names of the first ports are aliases:
|
||||
`busy`→`info`, `ok`→`success`, `warn`→`warning`, `err`/`error`/`emergency`→`danger`,
|
||||
`stale`→`subtle`. In QML use `theme.tone(name)`, `theme.toneText(name)`, `theme.toneTint(name)`;
|
||||
in Python `tokens.tone(name)`, `tokens.tone_text(name)`, `tokens.tone_tint(name)`. When a QML
|
||||
binding calls one of these, write it as `(theme.name, theme.toneText(tone))` so it re-evaluates on
|
||||
a theme switch.
|
||||
|
||||
**Aliases for existing ports.** `QmlTheme` also answers to the token names used on the earlier
|
||||
branches, so their QML runs unchanged when they switch to this host: `muted`, `faint`, `text`,
|
||||
`foreground`, `background`, `accent`, `busy`, `ok`, `warn`, `err`, `emergency`, `isDark`,
|
||||
`busyText`, `okText`, `warnText`, `errText`, `busyTint`, `okTint`, `warnTint`, `errTint`,
|
||||
`window`, `base`, `button`, `onAccent` and `c` (the raw `bec_qthemes` palette as a map, e.g.
|
||||
`theme.c.ACCENT_DEFAULT`). New code uses the canonical names. One difference to note: on the
|
||||
positioner-box branch `accent` meant the selection colour; here it is `ACCENT_DEFAULT`, so use
|
||||
`primary` where that branch used `accent`.
|
||||
|
||||
## Controls
|
||||
|
||||
Every control exists in both technologies with the same name and the same states.
|
||||
|
||||
| Control | QML properties | QWidget (`ux_kit`) | Use for |
|
||||
| --- | --- | --- | --- |
|
||||
| `Icon` | `name`, `color`, `size`, `filled` | `material_icon(...)` from `bec_qthemes` | Material icons via `image://material/<name>` |
|
||||
| `IconButton` | `iconName`, `tip`, `danger`, `compact`, `checkable`, `iconSize` | `IconButton(icon_name, tip, danger=, compact=)` | toolbar and row actions; `tip` is the tooltip and accessible name, never leave it empty |
|
||||
| `TextButton` | `text`, `variant`, `iconName`, `busy`, `compact`, `tip` | `TextButton(text, variant, icon_name, compact=)`, `set_busy()` | labelled actions. Variants: `primary`, `success`, `danger` (solid), `neutral` (outlined), `dangerOutline`, `ghost` |
|
||||
| `Spinner` | `running`, `size`, `color` | `Spinner(size=)`, `set_running()` | short waits inside a row |
|
||||
| `StatusPill` | `text`, `tone`, `iconName`, `pulse`, `outlined` | `StatusPill(text=, tone=, icon_name=, pulse=, outlined=)`, `set_status()` | state of a device, scan or service. `tone` is a name or a colour; `pulse` for live states |
|
||||
| `Badge` | `count`, `tone`, `solid`, `showZero`, `maximum` | `Badge(count=, tone=)`, `set_count()` | unread counts, filter counts |
|
||||
| `LinearProgress` | `value` (0..1), `indeterminate`, `tone`, `thickness` | `LinearProgress(tone=)`, `set_value()`, `set_indeterminate()` | progress inside rows and cards |
|
||||
| `Card` | `title`, `subtitle`, `iconName`, `titleStyle`, `collapsible`, `expanded`, `headerExtras`, `fillContent` | `Card(title, title_style=, collapsible=, surface=)`, `.body`, `add_header_widget()` | every grouped surface. `titleStyle: "caption"` for form sections |
|
||||
| `Divider` | `vertical` | `Divider(vertical=)` | hairlines |
|
||||
| `Banner` | `tone`, `title`, `text`, `actionText`, `closable` | `Banner(tone, title, text, action_text=, closable=)` | inline errors, warnings and confirmations that belong to a place in the UI |
|
||||
| `EmptyState` | `iconName`, `title`, `text`, `actionText`, `compact` | `EmptyState(icon_name, title, text, action_text=)` | empty lists and views: say what will appear and how to get it |
|
||||
| `FieldFrame` | `focused`, `invalid`, `hovered` | `field_qss(tokens)`, `set_invalid()` | background of all inputs |
|
||||
| `InputField` | `suffix`, `iconName`, `invalid`, `monospace` | `SuffixLineEdit`, `set_suffix()`, `set_icon_name()` | text and numeric entry with units |
|
||||
| `SearchField` | `shortcutHint`, `cleared()` | `SearchField(placeholder=, shortcut_hint=)` | filtering lists; Escape clears |
|
||||
| `SelectField` | `invalid`, `placeholder`, `editable` | `QComboBox` + `field_qss` | choosing from a list |
|
||||
| `SwitchField` | `text`, `checked` | `ToggleSwitch` | booleans |
|
||||
| `SegmentedControl` | `model`, `currentIndex`, `compact`, `activated(index)` | `SegmentedControl(items)`, `activated`, `set_count()` | 2–5 exclusive filters or views |
|
||||
| `FormField` | `label`, `required`, `unit`, `helper`, `error` | `FormField(label, control, required=, helper=, unit=)`, `set_error()` | label, helper and validation message around any input |
|
||||
|
||||
QWidget controls follow `app.theme` by themselves. `refresh_kit_theme(root)` re-themes a subtree
|
||||
created before a theme was applied. `PortedPropertiesMixin` keeps the `SafeProperty` settings of
|
||||
the class a port replaces in saved profiles.
|
||||
|
||||
## Hosting QML
|
||||
|
||||
```python
|
||||
from bec_widgets.utils.quick import create_quick_widget, release_quick_widget
|
||||
|
||||
self.view = create_quick_widget(self, QML_FILE, properties={"backend": self._backend})
|
||||
layout.addWidget(self.view)
|
||||
...
|
||||
def cleanup(self):
|
||||
release_quick_widget(self.view) # unload the scene before the backend dies
|
||||
super().cleanup()
|
||||
```
|
||||
|
||||
* All views share one `QQmlEngine` (`quick_engine()`), so the second and later QML widgets cost
|
||||
little memory. Hand state to the view with `properties` (initial properties of the root item).
|
||||
* `context={"name": obj}` still works for the first ports, but gives the view its own engine,
|
||||
because context properties on the shared engine would leak into every other view.
|
||||
* `background` is `"window"` (default), `"card"` or `"transparent"` and follows theme switches.
|
||||
* `raise_on_error=True` turns QML load errors into a `RuntimeError`; use it in tests.
|
||||
* `DictListModel(roles)` feeds a list of dicts to a QML `ListView`, updating rows in place so
|
||||
delegates and their animations survive refreshes.
|
||||
* Icons: `image://material/<name>?color=%23rrggbb&filled=1`. The colour may also be `rrggbb`
|
||||
without `#`, `#aarrggbb` or a colour name. `Icon { name: ...; color: ... }` builds the URL.
|
||||
|
||||
## Rules the controls already follow
|
||||
|
||||
* Every interactive control shows keyboard focus with a 2 px primary ring.
|
||||
* Icon-only buttons always carry a tooltip, which is also their accessible name.
|
||||
* Colour never carries meaning alone: pills and banners pair the tone with a word or an icon.
|
||||
* Status text uses the `<tone>Text` colours, which stay readable on both themes (the light
|
||||
theme's yellow is darkened for text).
|
||||
* Destructive actions use `danger` only when they are the main action of a dialog; elsewhere
|
||||
`dangerOutline` or an `IconButton { danger: true }`.
|
||||
|
||||
## Adding a control
|
||||
|
||||
1. Add `BecUi/<Name>.qml` and list it in `BecUi/qmldir`.
|
||||
2. Add the QWidget twin to `ux_kit.py` (inherit `_ThemeFollower` and implement
|
||||
`refresh_theme(tokens)`), with the same name and states.
|
||||
3. Show both in `examples/ui_kit_gallery` and add a test to `tests/unit_tests/test_ui_kit.py`;
|
||||
the tests fail when a QML control is missing from `qmldir` or the gallery.
|
||||
@@ -1,26 +1,35 @@
|
||||
"""Shared plumbing for widgets that render their content with Qt Quick (QML).
|
||||
"""The BEC UI kit: shared plumbing and controls for QML and QWidget versions of BEC widgets.
|
||||
|
||||
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.
|
||||
* :mod:`bec_widgets.utils.quick.tokens`: colour and metric tokens from ``bec_qthemes``.
|
||||
* :mod:`bec_widgets.utils.quick.host`: the shared QML engine, theme bridge and icon provider.
|
||||
* ``bec_widgets/utils/quick/qml/BecUi``: the QML controls (``import BecUi``).
|
||||
* :mod:`bec_widgets.utils.ux_kit`: the matching QWidget controls.
|
||||
|
||||
See ``bec_widgets/utils/quick/README.md`` for the component reference.
|
||||
"""
|
||||
|
||||
from bec_widgets.utils.quick.host import (
|
||||
QML_IMPORT_PATH,
|
||||
MaterialIconProvider,
|
||||
QmlTheme,
|
||||
configure_engine,
|
||||
create_quick_widget,
|
||||
quick_engine,
|
||||
quick_theme,
|
||||
release_quick_widget,
|
||||
)
|
||||
from bec_widgets.utils.quick.list_model import DictListModel
|
||||
from bec_widgets.utils.quick.tokens import ThemeTokens
|
||||
|
||||
__all__ = [
|
||||
"QML_IMPORT_PATH",
|
||||
"DictListModel",
|
||||
"MaterialIconProvider",
|
||||
"QmlTheme",
|
||||
"ThemeTokens",
|
||||
"configure_engine",
|
||||
"create_quick_widget",
|
||||
"quick_engine",
|
||||
"quick_theme",
|
||||
"release_quick_widget",
|
||||
]
|
||||
+262
-106
@@ -5,6 +5,9 @@ application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.
|
||||
``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.
|
||||
|
||||
Views that still pass per-view context properties get their own engine, configured the same way,
|
||||
so the older ports can switch to this module without touching their QML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,87 +17,65 @@ 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.QtCore import Property, QObject, QSize, Qt, QUrl, Signal, Slot
|
||||
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
|
||||
|
||||
from bec_widgets.utils.quick.tokens import METRICS, ThemeTokens, app_theme
|
||||
from bec_widgets.utils.quick.tokens import blend as _blend # kept for ports importing it from here
|
||||
|
||||
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)
|
||||
__all__ = [
|
||||
"QML_IMPORT_PATH",
|
||||
"MaterialIconProvider",
|
||||
"QmlTheme",
|
||||
"ThemeTokens",
|
||||
"configure_engine",
|
||||
"create_quick_widget",
|
||||
"quick_engine",
|
||||
"quick_theme",
|
||||
"release_quick_widget",
|
||||
]
|
||||
|
||||
|
||||
class QmlTheme(QObject):
|
||||
"""Expose the BEC theme colours to QML as ``theme.<token>`` and follow theme changes."""
|
||||
"""Expose the BEC theme to QML as ``theme.<token>`` and follow theme changes.
|
||||
|
||||
Canonical colour names: ``bg``, ``card``, ``field``, ``border``, ``separator``, ``fg``,
|
||||
``fgMuted``, ``fgSubtle``, ``hover``, ``pressed``, ``track``, ``sunken``, ``primary``,
|
||||
``onPrimary``, ``info``, ``success``, ``warning``, ``danger``, ``highlight`` and, for each
|
||||
status colour, ``<tone>Text`` (readable on cards) and ``<tone>Tint`` (soft fill).
|
||||
|
||||
Metrics: ``radiusSmall``, ``radiusLarge``, ``controlHeight``, ``controlHeightCompact``,
|
||||
``spacing``, ``padding``, ``fontCaption``, ``fontSmall``, ``fontBody``, ``fontTitle``,
|
||||
``fontHeadline``, ``fontDisplay`` and ``monoFamily``.
|
||||
|
||||
Tone helpers callable from QML: ``theme.tone(name)``, ``theme.toneText(name)`` and
|
||||
``theme.toneTint(name)``.
|
||||
|
||||
The names used by the first ports are kept as aliases (``muted``, ``faint``, ``text``,
|
||||
``foreground``, ``background``, ``accent``, ``busy``, ``ok``, ``warn``, ``err``,
|
||||
``emergency``, ``isDark``, ``busyText``, ``okTint``, ``window``, ``base``, ``button``,
|
||||
``onAccent`` and the raw palette map ``c``), so their QML runs unchanged.
|
||||
"""
|
||||
|
||||
changed = Signal()
|
||||
|
||||
# pylint: disable=no-self-argument
|
||||
def _color_property(token: str, notify=changed):
|
||||
return Property(QColor, lambda self: QColor(getattr(self._tokens, token)), notify=notify)
|
||||
|
||||
def _metric_property(key: str):
|
||||
return Property(int, lambda self: METRICS[key], constant=True)
|
||||
|
||||
# pylint: enable=no-self-argument
|
||||
|
||||
def __init__(self, parent: QObject | None = None):
|
||||
super().__init__(parent)
|
||||
self._tokens = ThemeTokens()
|
||||
@@ -102,46 +83,151 @@ class QmlTheme(QObject):
|
||||
self._connect_theme()
|
||||
|
||||
def _connect_theme(self) -> None:
|
||||
app = QApplication.instance()
|
||||
theme = getattr(app, "theme", None) if app is not None else None
|
||||
theme = app_theme()
|
||||
if theme is None or theme is self._connected_theme:
|
||||
return
|
||||
theme.theme_changed.connect(self.refresh)
|
||||
self._connected_theme = theme
|
||||
|
||||
@property
|
||||
def tokens(self) -> ThemeTokens:
|
||||
"""The tokens currently exposed to QML."""
|
||||
return self._tokens
|
||||
|
||||
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)
|
||||
@Slot(str, result=QColor)
|
||||
def tone(self, name: str) -> QColor:
|
||||
"""Base colour of a status tone, e.g. ``theme.tone("warning")``."""
|
||||
return self._tokens.tone(name)
|
||||
|
||||
@Slot(str, result=QColor)
|
||||
def toneText(self, name: str) -> QColor: # pylint: disable=invalid-name
|
||||
"""Readable text colour of a status tone."""
|
||||
return self._tokens.tone_text(name)
|
||||
|
||||
@Slot(str, result=QColor)
|
||||
def toneTint(self, name: str) -> QColor: # pylint: disable=invalid-name
|
||||
"""Soft background fill of a status tone."""
|
||||
return self._tokens.tone_tint(name)
|
||||
|
||||
# pylint: disable=invalid-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)
|
||||
monoFamily = Property(str, lambda self: self._tokens.mono_family, notify=changed)
|
||||
|
||||
bg = _color_property("bg")
|
||||
card = _color_property("card")
|
||||
field = _color_property("field")
|
||||
border = _color_property("border")
|
||||
separator = _color_property("separator")
|
||||
fg = _color_property("fg")
|
||||
fgMuted = _color_property("fg_muted")
|
||||
fgSubtle = _color_property("fg_subtle")
|
||||
hover = _color_property("hover")
|
||||
pressed = _color_property("pressed")
|
||||
track = _color_property("track")
|
||||
sunken = _color_property("sunken")
|
||||
primary = _color_property("primary")
|
||||
onPrimary = _color_property("on_primary")
|
||||
info = _color_property("info")
|
||||
success = _color_property("success")
|
||||
warning = _color_property("warning")
|
||||
danger = _color_property("danger")
|
||||
highlight = _color_property("highlight")
|
||||
infoText = _color_property("info_text")
|
||||
successText = _color_property("success_text")
|
||||
warningText = _color_property("warning_text")
|
||||
dangerText = _color_property("danger_text")
|
||||
highlightText = _color_property("highlight_text")
|
||||
infoTint = _color_property("info_tint")
|
||||
successTint = _color_property("success_tint")
|
||||
warningTint = _color_property("warning_tint")
|
||||
dangerTint = _color_property("danger_tint")
|
||||
highlightTint = _color_property("highlight_tint")
|
||||
|
||||
radiusSmall = _metric_property("radiusSmall")
|
||||
radiusLarge = _metric_property("radiusLarge")
|
||||
controlHeight = _metric_property("controlHeight")
|
||||
controlHeightCompact = _metric_property("controlHeightCompact")
|
||||
spacing = _metric_property("spacing")
|
||||
padding = _metric_property("padding")
|
||||
fontCaption = _metric_property("fontCaption")
|
||||
fontSmall = _metric_property("fontSmall")
|
||||
fontBody = _metric_property("fontBody")
|
||||
fontTitle = _metric_property("fontTitle")
|
||||
fontHeadline = _metric_property("fontHeadline")
|
||||
fontDisplay = _metric_property("fontDisplay")
|
||||
|
||||
# Aliases for the token names of the first ports (queue, status box, beamline states, fit
|
||||
# dialog). New QML should use the canonical names above.
|
||||
isDark = Property(bool, lambda self: self._tokens.dark, notify=changed)
|
||||
background = _color_property("bg")
|
||||
text = _color_property("fg")
|
||||
foreground = _color_property("fg")
|
||||
muted = _color_property("fg_muted")
|
||||
faint = _color_property("fg_subtle")
|
||||
accent = _color_property("accent")
|
||||
busy = _color_property("info")
|
||||
ok = _color_property("success")
|
||||
warn = _color_property("warning")
|
||||
err = _color_property("danger")
|
||||
emergency = _color_property("danger")
|
||||
busyText = _color_property("info_text")
|
||||
okText = _color_property("success_text")
|
||||
warnText = _color_property("warning_text")
|
||||
errText = _color_property("danger_text")
|
||||
busyTint = _color_property("info_tint")
|
||||
okTint = _color_property("success_tint")
|
||||
warnTint = _color_property("warning_tint")
|
||||
errTint = _color_property("danger_tint")
|
||||
window = _color_property("bg")
|
||||
base = _color_property("field")
|
||||
button = _color_property("card")
|
||||
onAccent = _color_property("on_primary")
|
||||
|
||||
@Property("QVariantMap", notify=changed)
|
||||
def c(self) -> dict:
|
||||
"""The raw ``bec_qthemes`` palette as ``{KEY: "#rrggbb"}``, e.g. ``theme.c.ACCENT_DEFAULT``."""
|
||||
theme = app_theme()
|
||||
colors = dict(getattr(theme, "colors", None) or {})
|
||||
return {
|
||||
key: QColor(value).name() for key, value in colors.items() if QColor(value).isValid()
|
||||
}
|
||||
|
||||
# pylint: enable=invalid-name
|
||||
|
||||
del _color_property, _metric_property
|
||||
|
||||
|
||||
def _parse_icon_color(value: str | None) -> str | None:
|
||||
"""Normalise the colour of an icon URL to ``#rrggbb`` or ``#rrggbbaa`` for bec_qthemes.
|
||||
|
||||
Accepts ``#rrggbb``, ``rrggbb`` (no hash), ``#aarrggbb`` (QML's ``color.toString()`` for
|
||||
translucent colours) and SVG colour names such as ``red``.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if not value.startswith("#") and all(c in "0123456789abcdefABCDEF" for c in value):
|
||||
value = f"#{value}"
|
||||
color = QColor(value)
|
||||
if not color.isValid():
|
||||
return None
|
||||
if color.alpha() == 255:
|
||||
return color.name()
|
||||
return f"{color.name()}{color.alpha():02x}"
|
||||
|
||||
|
||||
class MaterialIconProvider(QQuickImageProvider):
|
||||
"""Serve Material icons to QML as ``image://material/<name>?color=%23rrggbb&filled=1``."""
|
||||
"""Serve Material icons to QML as ``image://material/<name>?color=%23rrggbb&filled=1``.
|
||||
|
||||
The colour may also be given without ``#`` (``color=40B6E0``). Unknown icon names give an
|
||||
empty pixmap and a log message instead of an exception.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(QQuickImageProvider.ImageType.Pixmap)
|
||||
@@ -149,12 +235,19 @@ class MaterialIconProvider(QQuickImageProvider):
|
||||
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]
|
||||
color = _parse_icon_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)
|
||||
pixmap = QPixmap()
|
||||
if name and name != "undefined":
|
||||
try:
|
||||
pixmap = material_icon(
|
||||
name, size=target, color=color, filled=filled, convert_to_pixmap=True
|
||||
)
|
||||
except (KeyError, FileNotFoundError, ValueError):
|
||||
logger.warning(f"Unknown Material icon requested from QML: {name}")
|
||||
if size is not None:
|
||||
size.setWidth(pixmap.width())
|
||||
size.setHeight(pixmap.height())
|
||||
@@ -165,56 +258,119 @@ _ENGINE: QQmlEngine | None = None
|
||||
_THEME: QmlTheme | None = None
|
||||
|
||||
|
||||
def configure_engine(engine: QQmlEngine, theme: QmlTheme | None = None) -> QmlTheme:
|
||||
"""Install the ``BecUi`` import path, the icon provider and the ``theme`` property.
|
||||
|
||||
Args:
|
||||
engine(QQmlEngine): Engine to configure.
|
||||
theme(QmlTheme | None): Theme bridge to expose; a new one owned by the engine if None.
|
||||
|
||||
Returns:
|
||||
QmlTheme: The theme bridge exposed as ``theme``.
|
||||
"""
|
||||
engine.addImportPath(str(QML_IMPORT_PATH))
|
||||
engine.addImageProvider("material", MaterialIconProvider())
|
||||
theme = theme or QmlTheme(engine)
|
||||
engine.rootContext().setContextProperty("theme", theme)
|
||||
engine.warnings.connect(
|
||||
lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings]
|
||||
)
|
||||
return theme
|
||||
|
||||
|
||||
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
|
||||
if _THEME._connected_theme is not app_theme(): # 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]
|
||||
)
|
||||
_THEME = configure_engine(_ENGINE)
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(_drop_engine)
|
||||
return _ENGINE
|
||||
|
||||
|
||||
def quick_theme() -> QmlTheme:
|
||||
"""Return the theme bridge of the shared engine."""
|
||||
quick_engine()
|
||||
return _THEME
|
||||
|
||||
|
||||
def _drop_engine() -> None:
|
||||
global _ENGINE, _THEME # pylint: disable=global-statement
|
||||
_ENGINE = None
|
||||
_THEME = None
|
||||
|
||||
|
||||
class _ClearColorFollower(QObject):
|
||||
"""Keeps the clear colour of a view in sync with the theme; dies with the view."""
|
||||
|
||||
def __init__(self, view: QQuickWidget, theme: QmlTheme, background: str):
|
||||
super().__init__(view)
|
||||
self._view = view
|
||||
self._theme = theme
|
||||
self._background = background
|
||||
theme.changed.connect(self.apply)
|
||||
self.apply()
|
||||
|
||||
@Slot()
|
||||
def apply(self) -> None:
|
||||
"""Apply the clear colour for the current theme."""
|
||||
tokens = self._theme.tokens
|
||||
color = {"window": tokens.bg, "card": tokens.card}.get(self._background, QColor(0, 0, 0, 0))
|
||||
self._view.setClearColor(color)
|
||||
|
||||
|
||||
def create_quick_widget(
|
||||
parent: QWidget, qml_file: str | Path, properties: dict | None = None
|
||||
parent: QWidget,
|
||||
qml_file: str | Path,
|
||||
properties: dict | None = None,
|
||||
context: dict[str, QObject] | None = None,
|
||||
background: str = "window",
|
||||
raise_on_error: bool = False,
|
||||
) -> QQuickWidget:
|
||||
"""Create a QQuickWidget showing ``qml_file`` on the shared engine.
|
||||
"""Create a QQuickWidget showing ``qml_file`` with the BEC theme, icons and ``BecUi``.
|
||||
|
||||
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}``.
|
||||
This is the preferred way to hand state to a view.
|
||||
context(dict[str, QObject] | None): Context properties, as used by the first ports.
|
||||
Views with context properties get their own engine (about 10 MB each), because
|
||||
context properties of the shared engine would leak into every other view.
|
||||
background(str): ``"window"``, ``"card"`` or ``"transparent"``; follows theme changes.
|
||||
raise_on_error(bool): Raise ``RuntimeError`` when the QML fails to load instead of
|
||||
only logging it.
|
||||
|
||||
Returns:
|
||||
QQuickWidget: The view, already loaded. Load errors are logged.
|
||||
QQuickWidget: The view, already loaded.
|
||||
"""
|
||||
view = QQuickWidget(quick_engine(), parent)
|
||||
qml_file = Path(qml_file)
|
||||
if context:
|
||||
view = QQuickWidget(parent)
|
||||
theme = configure_engine(view.engine(), QmlTheme(view))
|
||||
for name, obj in context.items():
|
||||
view.rootContext().setContextProperty(name, obj)
|
||||
else:
|
||||
view = QQuickWidget(quick_engine(), parent)
|
||||
theme = _THEME
|
||||
view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
|
||||
view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
|
||||
view.setClearColor(parent.palette().window().color())
|
||||
view.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
_ClearColorFollower(view, theme, background)
|
||||
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()}")
|
||||
view.setSource(QUrl.fromLocalFile(str(qml_file.resolve())))
|
||||
errors = [error.toString() for error in view.errors()]
|
||||
for error in errors:
|
||||
logger.error(f"QML error in {qml_file.name}: {error}")
|
||||
if errors and raise_on_error:
|
||||
raise RuntimeError(f"Failed to load {qml_file.name}: {'; '.join(errors)}")
|
||||
return view
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import QtQuick
|
||||
|
||||
// Count bubble, e.g. unread notifications or items in a filter. Hidden at zero unless
|
||||
// showZero. tone: "primary", "danger", "warning", "neutral" ... ; solid paints a filled bubble.
|
||||
Rectangle {
|
||||
id: root
|
||||
property int count: 0
|
||||
property int maximum: 99
|
||||
property string tone: "danger"
|
||||
property bool solid: tone !== "neutral" && tone !== "primary"
|
||||
property bool showZero: false
|
||||
|
||||
readonly property color toneColor: (theme.name, tone === "primary" ? theme.primary : theme.tone(tone))
|
||||
readonly property string label: count > maximum ? maximum + "+" : String(count)
|
||||
|
||||
visible: count > 0 || showZero
|
||||
implicitHeight: 16
|
||||
implicitWidth: Math.max(implicitHeight, labelText.implicitWidth + 9)
|
||||
radius: implicitHeight / 2
|
||||
color: solid ? toneColor : Qt.rgba(toneColor.r, toneColor.g, toneColor.b, theme.dark ? 0.22 : 0.14)
|
||||
Accessible.name: label
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
anchors.centerIn: parent
|
||||
text: root.label
|
||||
color: root.solid ? (0.299 * root.toneColor.r + 0.587 * root.toneColor.g + 0.114 * root.toneColor.b > 0.6 ? "#1a1a1a" : "white")
|
||||
: (theme.name, theme.toneText(root.tone))
|
||||
font.pixelSize: 10
|
||||
font.weight: Font.Bold
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Inline message inside a card or above a form: tinted background, tone icon, title, text,
|
||||
// an optional action button and an optional close button.
|
||||
// tone: "info", "success", "warning" or "danger" (aliases accepted).
|
||||
Rectangle {
|
||||
id: root
|
||||
property string tone: "info"
|
||||
property string title: ""
|
||||
property string text: ""
|
||||
property string iconName: ""
|
||||
property string actionText: ""
|
||||
property bool closable: false
|
||||
signal actionTriggered()
|
||||
signal closed()
|
||||
|
||||
readonly property string _tone: tone === "ok" ? "success" : tone === "warn" ? "warning"
|
||||
: tone === "err" || tone === "error" ? "danger" : tone === "busy" ? "info" : tone
|
||||
readonly property string _icon: iconName !== "" ? iconName
|
||||
: _tone === "success" ? "check_circle" : _tone === "warning" ? "warning"
|
||||
: _tone === "danger" ? "error" : "info"
|
||||
|
||||
implicitHeight: layout.implicitHeight + 20
|
||||
implicitWidth: 320
|
||||
radius: theme.radiusSmall + 2
|
||||
color: (theme.name, theme.toneTint(_tone))
|
||||
border.color: Qt.alpha((theme.name, theme.tone(_tone)), 0.45)
|
||||
Accessible.role: Accessible.AlertMessage
|
||||
Accessible.name: title + " " + text
|
||||
|
||||
Rectangle {
|
||||
// accent bar
|
||||
width: 3
|
||||
radius: 1.5
|
||||
color: (theme.name, theme.tone(root._tone))
|
||||
anchors { left: parent.left; top: parent.top; bottom: parent.bottom; margins: 6 }
|
||||
}
|
||||
RowLayout {
|
||||
id: layout
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 16
|
||||
anchors.rightMargin: 8
|
||||
anchors.topMargin: 10
|
||||
anchors.bottomMargin: 10
|
||||
spacing: 10
|
||||
Icon {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 1
|
||||
name: root._icon
|
||||
filled: true
|
||||
size: 18
|
||||
color: (theme.name, theme.toneText(root._tone))
|
||||
}
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
visible: root.title !== ""
|
||||
Layout.fillWidth: true
|
||||
text: root.title
|
||||
color: theme.fg
|
||||
font.pixelSize: theme.fontBody
|
||||
font.weight: Font.DemiBold
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
Text {
|
||||
visible: root.text !== ""
|
||||
Layout.fillWidth: true
|
||||
text: root.text
|
||||
color: root.title !== "" ? theme.fgMuted : theme.fg
|
||||
font.pixelSize: root.title !== "" ? theme.fontSmall : theme.fontBody
|
||||
wrapMode: Text.WordWrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
TextButton {
|
||||
visible: root.actionText !== ""
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
compact: true
|
||||
variant: "neutral"
|
||||
text: root.actionText
|
||||
onClicked: root.actionTriggered()
|
||||
}
|
||||
IconButton {
|
||||
visible: root.closable
|
||||
Layout.alignment: Qt.AlignTop
|
||||
compact: true
|
||||
iconName: "close"
|
||||
tip: "Dismiss"
|
||||
onClicked: root.closed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,27 @@ import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Rounded surface with an optional title row. Children go into the content column.
|
||||
// titleStyle: "title" (13 px semibold) or "caption" (11 px uppercase, for form sections).
|
||||
// collapsible: the title row toggles the content; expanded holds the state.
|
||||
// headerExtras / trailing: items placed at the right end of the title row.
|
||||
// fillContent: the content column takes the remaining height, e.g. for a list.
|
||||
Rectangle {
|
||||
id: root
|
||||
property string title: ""
|
||||
property string subtitle: ""
|
||||
property string iconName: ""
|
||||
property string titleStyle: "title"
|
||||
property bool collapsible: false
|
||||
property bool expanded: true
|
||||
default property alias content: column.data
|
||||
property alias headerExtras: extras.data
|
||||
property int padding: 12
|
||||
property alias trailing: extras.data
|
||||
property int padding: theme.padding
|
||||
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
|
||||
radius: theme.radiusLarge
|
||||
border.color: theme.border
|
||||
border.width: 1
|
||||
implicitHeight: outer.implicitHeight + 2 * padding
|
||||
@@ -26,20 +34,34 @@ Rectangle {
|
||||
anchors.margins: root.padding
|
||||
spacing: 10
|
||||
RowLayout {
|
||||
id: header
|
||||
visible: root.title !== "" || extras.children.length > 0
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
spacing: 6
|
||||
Icon {
|
||||
visible: root.collapsible
|
||||
name: root.expanded ? "expand_more" : "chevron_right"
|
||||
size: 18
|
||||
color: theme.fgMuted
|
||||
}
|
||||
Icon {
|
||||
visible: root.iconName !== ""
|
||||
name: root.iconName
|
||||
size: 16
|
||||
color: theme.fgMuted
|
||||
}
|
||||
Text {
|
||||
text: root.title
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
font.weight: Font.DemiBold
|
||||
text: root.titleStyle === "caption" ? root.title.toUpperCase() : root.title
|
||||
color: root.titleStyle === "caption" ? theme.fgMuted : theme.fg
|
||||
font.pixelSize: root.titleStyle === "caption" ? theme.fontCaption : theme.fontBody
|
||||
font.weight: root.titleStyle === "caption" ? Font.Bold : Font.DemiBold
|
||||
font.letterSpacing: root.titleStyle === "caption" ? 0.8 : 0
|
||||
}
|
||||
Text {
|
||||
text: root.subtitle
|
||||
visible: text !== ""
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 12
|
||||
font.pixelSize: theme.fontSmall
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
@@ -48,9 +70,18 @@ Rectangle {
|
||||
}
|
||||
ColumnLayout {
|
||||
id: column
|
||||
visible: root.expanded
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: root.fillContent
|
||||
spacing: 8
|
||||
}
|
||||
}
|
||||
TapHandler {
|
||||
enabled: root.collapsible
|
||||
// only the title row toggles
|
||||
onTapped: (point) => {
|
||||
if (point.position.y <= root.padding + header.height + 4)
|
||||
root.expanded = !root.expanded
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Hairline separator. vertical: true for a vertical rule inside a row.
|
||||
Rectangle {
|
||||
property bool vertical: false
|
||||
color: theme.separator
|
||||
implicitWidth: vertical ? 1 : 10
|
||||
implicitHeight: vertical ? 10 : 1
|
||||
Layout.fillWidth: !vertical
|
||||
Layout.fillHeight: vertical
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Placeholder for an empty list or view: a large icon, a title, an explanation and an
|
||||
// optional action. Centre it in the area that would otherwise be blank.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
property string iconName: "inbox"
|
||||
property string title: ""
|
||||
property string text: ""
|
||||
property string actionText: ""
|
||||
property string actionIcon: ""
|
||||
property bool compact: false
|
||||
signal actionTriggered()
|
||||
|
||||
spacing: compact ? 4 : 8
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitWidth: root.compact ? 36 : 52
|
||||
implicitHeight: implicitWidth
|
||||
radius: implicitWidth / 2
|
||||
color: theme.hover
|
||||
Icon {
|
||||
anchors.centerIn: parent
|
||||
name: root.iconName
|
||||
size: root.compact ? 20 : 28
|
||||
color: theme.fgSubtle
|
||||
}
|
||||
}
|
||||
Text {
|
||||
visible: root.title !== ""
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: root.compact ? 2 : 4
|
||||
text: root.title
|
||||
color: theme.fg
|
||||
font.pixelSize: root.compact ? theme.fontBody : theme.fontTitle
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
visible: root.text !== ""
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.maximumWidth: 320
|
||||
text: root.text
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: root.compact ? theme.fontSmall : theme.fontBody
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
TextButton {
|
||||
visible: root.actionText !== ""
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: 6
|
||||
variant: "neutral"
|
||||
compact: root.compact
|
||||
iconName: root.actionIcon
|
||||
text: root.actionText
|
||||
onClicked: root.actionTriggered()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ Rectangle {
|
||||
property bool focused: false
|
||||
property bool invalid: false
|
||||
property bool hovered: false
|
||||
radius: 6
|
||||
radius: theme.radiusSmall
|
||||
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,67 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Label above a control, with a required mark, a helper line and an error line.
|
||||
// The error replaces the helper and turns red; put the control as the child.
|
||||
ColumnLayout {
|
||||
id: root
|
||||
property string label: ""
|
||||
property bool required: false
|
||||
property string helper: ""
|
||||
property string error: ""
|
||||
property string unit: ""
|
||||
default property alias control: slot.data
|
||||
|
||||
spacing: 4
|
||||
RowLayout {
|
||||
visible: root.label !== ""
|
||||
spacing: 4
|
||||
Text {
|
||||
text: root.label
|
||||
color: theme.fgMuted
|
||||
font.pixelSize: theme.fontSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
Text {
|
||||
visible: root.required
|
||||
text: "*"
|
||||
color: theme.dangerText
|
||||
font.pixelSize: theme.fontSmall
|
||||
Accessible.name: "required"
|
||||
}
|
||||
Text {
|
||||
visible: root.unit !== ""
|
||||
text: "(" + root.unit + ")"
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: theme.fontSmall
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
id: slot
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
// the control stretches to the width of the field
|
||||
onChildrenChanged: {
|
||||
for (let i = 0; i < children.length; ++i)
|
||||
children[i].Layout.fillWidth = true
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
visible: root.error !== "" || root.helper !== ""
|
||||
spacing: 4
|
||||
Icon {
|
||||
visible: root.error !== ""
|
||||
name: "error"
|
||||
filled: true
|
||||
size: 13
|
||||
color: theme.dangerText
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: root.error !== "" ? root.error : root.helper
|
||||
color: root.error !== "" ? theme.dangerText : theme.fgSubtle
|
||||
font.pixelSize: theme.fontSmall
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,24 +2,32 @@ import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Flat, square icon button with hover and pressed feedback and a tooltip.
|
||||
// Every icon-only button needs a tip: it is also the accessible name.
|
||||
// checkable + checked tints the icon with the primary colour; danger tints it red.
|
||||
AbstractButton {
|
||||
id: root
|
||||
property string iconName: ""
|
||||
property color iconColor: theme.fgMuted
|
||||
property string tip: ""
|
||||
property int iconSize: 18
|
||||
property int iconSize: compact ? 16 : 18
|
||||
property bool danger: false
|
||||
property bool compact: false
|
||||
property bool filled: false
|
||||
|
||||
implicitWidth: 30
|
||||
implicitHeight: 30
|
||||
implicitWidth: compact ? theme.controlHeightCompact : 30
|
||||
implicitHeight: compact ? theme.controlHeightCompact : 30
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
opacity: enabled ? 1.0 : 0.4
|
||||
Accessible.name: tip
|
||||
Accessible.role: Accessible.Button
|
||||
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent")
|
||||
border.width: root.visualFocus ? 1 : 0
|
||||
radius: theme.radiusSmall
|
||||
color: root.down ? theme.pressed
|
||||
: root.danger && root.hovered ? theme.dangerTint
|
||||
: (root.hovered || root.checked ? theme.hover : "transparent")
|
||||
border.width: root.visualFocus ? 2 : 0
|
||||
border.color: theme.primary
|
||||
}
|
||||
contentItem: Item {
|
||||
@@ -27,7 +35,8 @@ AbstractButton {
|
||||
anchors.centerIn: parent
|
||||
name: root.iconName
|
||||
size: root.iconSize
|
||||
color: root.checked ? theme.primary : root.iconColor
|
||||
filled: root.filled || root.checked
|
||||
color: root.checked ? theme.primary : root.danger ? theme.dangerText : root.iconColor
|
||||
}
|
||||
}
|
||||
ToolTip.visible: tip !== "" && hovered
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Single-line text input with optional unit suffix and error state.
|
||||
// Single-line text input with optional unit suffix, leading icon and error state.
|
||||
TextField {
|
||||
id: root
|
||||
property bool invalid: false
|
||||
property string suffix: ""
|
||||
property string iconName: ""
|
||||
property bool monospace: false
|
||||
|
||||
implicitHeight: 32
|
||||
implicitHeight: theme.controlHeight
|
||||
implicitWidth: 120
|
||||
color: theme.fg
|
||||
placeholderTextColor: theme.fgSubtle
|
||||
selectionColor: theme.primary
|
||||
selectedTextColor: theme.onPrimary
|
||||
font.pixelSize: 13
|
||||
leftPadding: 9
|
||||
font.pixelSize: theme.fontBody
|
||||
font.family: monospace ? theme.monoFamily : Qt.application.font.family
|
||||
leftPadding: iconName !== "" ? 30 : 9
|
||||
rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9
|
||||
selectByMouse: true
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1.0 : 0.5
|
||||
Accessible.name: placeholderText
|
||||
|
||||
background: FieldFrame {
|
||||
focused: root.activeFocus
|
||||
invalid: root.invalid
|
||||
hovered: root.hovered
|
||||
}
|
||||
Icon {
|
||||
visible: root.iconName !== ""
|
||||
name: root.iconName
|
||||
size: 16
|
||||
color: root.activeFocus ? theme.primary : theme.fgSubtle
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
id: suffixLabel
|
||||
visible: root.suffix !== ""
|
||||
text: root.suffix
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: 12
|
||||
font.pixelSize: theme.fontSmall
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import QtQuick
|
||||
|
||||
// Thin progress bar. value: 0..1; indeterminate: a sliding segment for unknown duration.
|
||||
// tone: "primary" (default), "success", "warning", "danger" ... ; the fill animates.
|
||||
Item {
|
||||
id: root
|
||||
property real value: 0
|
||||
property bool indeterminate: false
|
||||
property string tone: "primary"
|
||||
property int thickness: 6
|
||||
|
||||
readonly property color toneColor: (theme.name, tone === "primary" ? theme.primary : theme.tone(tone))
|
||||
|
||||
implicitHeight: thickness
|
||||
implicitWidth: 160
|
||||
Accessible.role: Accessible.ProgressBar
|
||||
Accessible.name: indeterminate ? "busy" : Math.round(value * 100) + "%"
|
||||
|
||||
Rectangle {
|
||||
id: track
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: theme.track
|
||||
clip: true
|
||||
Rectangle {
|
||||
id: fill
|
||||
visible: !root.indeterminate
|
||||
height: parent.height
|
||||
radius: height / 2
|
||||
width: Math.max(root.value > 0 ? height : 0, parent.width * Math.max(0, Math.min(1, root.value)))
|
||||
color: root.toneColor
|
||||
Behavior on width { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } }
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
}
|
||||
Rectangle {
|
||||
id: runner
|
||||
visible: root.indeterminate
|
||||
height: parent.height
|
||||
radius: height / 2
|
||||
width: parent.width * 0.3
|
||||
color: root.toneColor
|
||||
NumberAnimation on x {
|
||||
running: root.indeterminate && root.visible
|
||||
from: -runner.width
|
||||
to: track.width
|
||||
duration: 1300
|
||||
loops: Animation.Infinite
|
||||
easing.type: Easing.InOutQuad
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
|
||||
// Search input: magnifier icon, placeholder, a clear button once text is typed and an
|
||||
// optional shortcut hint (e.g. "Ctrl K"). Escape clears the text.
|
||||
InputField {
|
||||
id: root
|
||||
property string shortcutHint: ""
|
||||
signal cleared()
|
||||
|
||||
iconName: "search"
|
||||
placeholderText: "Search"
|
||||
rightPadding: clear.visible ? 34 : hint.visible ? hint.implicitWidth + 18 : 9
|
||||
Keys.onEscapePressed: (event) => {
|
||||
if (root.text !== "") { root.clear(); root.cleared(); event.accepted = true }
|
||||
else event.accepted = false
|
||||
}
|
||||
|
||||
IconButton {
|
||||
id: clear
|
||||
visible: root.text !== ""
|
||||
compact: true
|
||||
implicitWidth: 24
|
||||
implicitHeight: 24
|
||||
iconSize: 15
|
||||
iconName: "close"
|
||||
tip: "Clear"
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
onClicked: { root.clear(); root.cleared(); root.forceActiveFocus() }
|
||||
}
|
||||
Rectangle {
|
||||
id: hint
|
||||
visible: root.shortcutHint !== "" && root.text === "" && !root.activeFocus
|
||||
implicitWidth: hintText.implicitWidth + 10
|
||||
implicitHeight: 18
|
||||
radius: 4
|
||||
color: "transparent"
|
||||
border.color: theme.border
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Text {
|
||||
id: hintText
|
||||
anchors.centerIn: parent
|
||||
text: root.shortcutHint
|
||||
color: theme.fgSubtle
|
||||
font.pixelSize: theme.fontCaption
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A row of mutually exclusive options, e.g. a filter (All / Errors / Warnings) or a view
|
||||
// switch. model: list of strings or of {text, iconName, count} objects.
|
||||
// activated(index) fires on user clicks only; currentIndex is the state.
|
||||
Rectangle {
|
||||
id: root
|
||||
property var model: []
|
||||
property int currentIndex: 0
|
||||
property bool compact: false
|
||||
signal activated(int index)
|
||||
|
||||
implicitHeight: compact ? theme.controlHeightCompact : theme.controlHeight
|
||||
implicitWidth: row.implicitWidth + 6
|
||||
radius: theme.radiusSmall + 1
|
||||
color: theme.sunken
|
||||
border.color: theme.border
|
||||
Accessible.role: Accessible.PageTabList
|
||||
|
||||
function _text(entry) { return typeof entry === "string" ? entry : (entry.text || "") }
|
||||
function _icon(entry) { return typeof entry === "string" ? "" : (entry.iconName || "") }
|
||||
function _count(entry) { return typeof entry === "string" || entry.count === undefined ? -1 : entry.count }
|
||||
|
||||
Rectangle {
|
||||
// sliding selection marker
|
||||
id: marker
|
||||
readonly property Item target: repeater.count > root.currentIndex && root.currentIndex >= 0 ? repeater.itemAt(root.currentIndex) : null
|
||||
visible: target !== null
|
||||
x: target ? row.x + target.x : 0
|
||||
width: target ? target.width : 0
|
||||
y: 3
|
||||
height: root.height - 6
|
||||
radius: theme.radiusSmall - 1
|
||||
color: theme.card
|
||||
border.color: theme.dark ? theme.border : Qt.darker(theme.border, 1.05)
|
||||
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
|
||||
Behavior on width { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
RowLayout {
|
||||
id: row
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 3
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: root.height - 6
|
||||
spacing: 0
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: root.model
|
||||
delegate: Item {
|
||||
id: segment
|
||||
required property int index
|
||||
required property var modelData
|
||||
readonly property bool selected: root.currentIndex === index
|
||||
Layout.fillHeight: true
|
||||
implicitWidth: content.implicitWidth + (root.compact ? 16 : 22)
|
||||
Accessible.role: Accessible.PageTab
|
||||
Accessible.name: root._text(modelData)
|
||||
RowLayout {
|
||||
id: content
|
||||
anchors.centerIn: parent
|
||||
spacing: 5
|
||||
Icon {
|
||||
visible: root._icon(segment.modelData) !== ""
|
||||
name: root._icon(segment.modelData)
|
||||
size: 15
|
||||
color: segment.selected ? theme.fg : theme.fgMuted
|
||||
}
|
||||
Text {
|
||||
text: root._text(segment.modelData)
|
||||
color: segment.selected ? theme.fg : (hover.hovered ? theme.fg : theme.fgMuted)
|
||||
font.pixelSize: root.compact ? theme.fontSmall : theme.fontBody
|
||||
font.weight: segment.selected ? Font.DemiBold : Font.Medium
|
||||
}
|
||||
Badge {
|
||||
visible: root._count(segment.modelData) >= 0
|
||||
count: Math.max(0, root._count(segment.modelData))
|
||||
tone: segment.selected ? "primary" : "neutral"
|
||||
showZero: true
|
||||
}
|
||||
}
|
||||
HoverHandler { id: hover; cursorShape: Qt.PointingHandCursor }
|
||||
TapHandler {
|
||||
onTapped: { root.currentIndex = segment.index; root.activated(segment.index) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ ComboBox {
|
||||
property bool invalid: false
|
||||
property string placeholder: ""
|
||||
|
||||
implicitHeight: 32
|
||||
implicitHeight: theme.controlHeight
|
||||
implicitWidth: 140
|
||||
font.pixelSize: 13
|
||||
font.pixelSize: theme.fontBody
|
||||
hoverEnabled: true
|
||||
opacity: enabled ? 1.0 : 0.5
|
||||
|
||||
@@ -53,7 +53,7 @@ ComboBox {
|
||||
contentItem: Text {
|
||||
text: option.modelData === "" ? "None" : option.modelData
|
||||
color: option.modelData === "" ? theme.fgSubtle : theme.fg
|
||||
font.pixelSize: 13
|
||||
font.pixelSize: theme.fontBody
|
||||
elide: Text.ElideRight
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
@@ -82,7 +82,7 @@ ComboBox {
|
||||
background: Rectangle {
|
||||
color: theme.card
|
||||
border.color: theme.border
|
||||
radius: 8
|
||||
radius: theme.radiusSmall + 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import QtQuick
|
||||
import QtQuick.Shapes
|
||||
|
||||
// Small indeterminate spinner: a rotating arc in the given colour.
|
||||
Item {
|
||||
id: root
|
||||
property bool running: true
|
||||
property int size: 16
|
||||
property color color: theme.primary
|
||||
property real lineWidth: Math.max(1.5, size / 8)
|
||||
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
visible: running
|
||||
|
||||
Shape {
|
||||
anchors.fill: parent
|
||||
preferredRendererType: Shape.CurveRenderer
|
||||
RotationAnimator on rotation {
|
||||
from: 0; to: 360
|
||||
duration: 900
|
||||
loops: Animation.Infinite
|
||||
running: root.running && root.visible
|
||||
}
|
||||
ShapePath {
|
||||
strokeColor: Qt.alpha(root.color, 0.2)
|
||||
strokeWidth: root.lineWidth
|
||||
fillColor: "transparent"
|
||||
PathAngleArc {
|
||||
centerX: root.size / 2; centerY: root.size / 2
|
||||
radiusX: (root.size - root.lineWidth) / 2; radiusY: radiusX
|
||||
startAngle: 0; sweepAngle: 360
|
||||
}
|
||||
}
|
||||
ShapePath {
|
||||
strokeColor: root.color
|
||||
strokeWidth: root.lineWidth
|
||||
fillColor: "transparent"
|
||||
capStyle: ShapePath.RoundCap
|
||||
PathAngleArc {
|
||||
centerX: root.size / 2; centerY: root.size / 2
|
||||
radiusX: (root.size - root.lineWidth) / 2; radiusY: radiusX
|
||||
startAngle: -90; sweepAngle: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,36 @@
|
||||
import QtQuick
|
||||
|
||||
// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving".
|
||||
// Small rounded status chip: a coloured dot (or an icon) and a short label.
|
||||
// tone: a tone name ("neutral", "info", "success", "warning", "danger", "subtle" and the
|
||||
// aliases busy/ok/warn/err/stale) or any colour, e.g. theme.success.
|
||||
// pulse: the dot breathes, for live states such as "Moving" or "Running".
|
||||
// outlined: transparent fill with a hairline border, for quiet states that need no attention.
|
||||
Rectangle {
|
||||
id: root
|
||||
property string text: ""
|
||||
property color tone: theme.fgMuted
|
||||
property var tone: "neutral"
|
||||
property string iconName: ""
|
||||
property bool pulse: false
|
||||
property bool outlined: false
|
||||
|
||||
readonly property bool namedTone: typeof tone === "string"
|
||||
// "theme.name," makes the binding re-evaluate when the theme changes
|
||||
readonly property color toneColor: namedTone ? (theme.name, theme.toneText(tone)) : tone
|
||||
|
||||
implicitHeight: 22
|
||||
implicitWidth: label.implicitWidth + 26
|
||||
implicitWidth: label.implicitWidth + (iconName !== "" ? 30 : 26)
|
||||
radius: height / 2
|
||||
color: Qt.rgba(tone.r, tone.g, tone.b, 0.16)
|
||||
color: outlined ? "transparent" : Qt.rgba(toneColor.r, toneColor.g, toneColor.b, theme.dark ? 0.18 : 0.13)
|
||||
border.width: outlined ? 1 : 0
|
||||
border.color: theme.separator
|
||||
Accessible.name: text
|
||||
Behavior on color { ColorAnimation { duration: 150 } }
|
||||
|
||||
Rectangle {
|
||||
id: dot
|
||||
visible: root.iconName === ""
|
||||
width: 7; height: 7; radius: 3.5
|
||||
color: root.tone
|
||||
color: root.toneColor
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 9
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -27,14 +42,24 @@ Rectangle {
|
||||
NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad }
|
||||
}
|
||||
}
|
||||
Icon {
|
||||
id: glyph
|
||||
visible: root.iconName !== ""
|
||||
name: root.iconName
|
||||
size: 14
|
||||
color: root.toneColor
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 7
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
Text {
|
||||
id: label
|
||||
anchors.left: dot.right
|
||||
anchors.leftMargin: 6
|
||||
anchors.left: root.iconName !== "" ? glyph.right : dot.right
|
||||
anchors.leftMargin: root.iconName !== "" ? 4 : 6
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.text
|
||||
color: root.tone
|
||||
font.pixelSize: 12
|
||||
color: root.toneColor
|
||||
font.pixelSize: theme.fontSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import QtQuick.Controls.Basic
|
||||
// Toggle switch for boolean parameters.
|
||||
Switch {
|
||||
id: root
|
||||
implicitHeight: 32
|
||||
implicitHeight: theme.controlHeight
|
||||
hoverEnabled: true
|
||||
padding: 0
|
||||
indicator: Rectangle {
|
||||
@@ -19,7 +19,7 @@ Switch {
|
||||
Rectangle {
|
||||
x: root.checked ? parent.width - width - 3 : 3
|
||||
y: 3
|
||||
width: 16; height: 16; radius: 8
|
||||
width: 16; height: 16; radius: theme.radiusSmall + 2
|
||||
color: root.checked ? theme.onPrimary : theme.fgMuted
|
||||
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
|
||||
}
|
||||
@@ -28,7 +28,7 @@ Switch {
|
||||
leftPadding: root.indicator.width + 8
|
||||
text: root.text
|
||||
color: theme.fg
|
||||
font.pixelSize: 13
|
||||
font.pixelSize: theme.fontBody
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -2,33 +2,44 @@ import QtQuick
|
||||
import QtQuick.Controls.Basic
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost".
|
||||
// Labelled button.
|
||||
// variant: "primary", "danger", "success" (solid), "neutral" (outlined), "dangerOutline"
|
||||
// (red outline for destructive actions that are not the main action) or "ghost" (no border).
|
||||
// compact: 28 px high for toolbars and dense rows. busy: shows a spinner instead of the icon.
|
||||
AbstractButton {
|
||||
id: root
|
||||
property string variant: "neutral"
|
||||
property string iconName: ""
|
||||
property string tip: ""
|
||||
property bool busy: false
|
||||
property bool compact: false
|
||||
|
||||
readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success"
|
||||
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
|
||||
readonly property color labelColor: variant === "primary" ? theme.onPrimary
|
||||
: solid ? "white"
|
||||
: variant === "dangerOutline" ? theme.dangerText
|
||||
: theme.fg
|
||||
|
||||
implicitHeight: 32
|
||||
implicitWidth: Math.max(64, row.implicitWidth + 24)
|
||||
implicitHeight: compact ? theme.controlHeightCompact : theme.controlHeight
|
||||
implicitWidth: Math.max(compact ? 48 : 64, row.implicitWidth + (compact ? 18 : 24))
|
||||
hoverEnabled: true
|
||||
focusPolicy: Qt.TabFocus
|
||||
opacity: enabled ? 1.0 : 0.45
|
||||
Accessible.name: text
|
||||
Accessible.role: Accessible.Button
|
||||
|
||||
background: Rectangle {
|
||||
radius: 6
|
||||
radius: theme.radiusSmall
|
||||
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
|
||||
: root.variant === "dangerOutline" && (root.hovered || root.down) ? theme.dangerTint
|
||||
: (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent")
|
||||
border.width: root.visualFocus ? 2 : (root.variant === "neutral" || root.variant === "dangerOutline" ? 1 : 0)
|
||||
border.color: root.visualFocus ? theme.primary
|
||||
: root.variant === "dangerOutline" ? Qt.alpha(theme.danger, 0.6) : theme.border
|
||||
Behavior on color { ColorAnimation { duration: 90 } }
|
||||
}
|
||||
contentItem: Item {
|
||||
@@ -38,22 +49,22 @@ AbstractButton {
|
||||
id: row
|
||||
anchors.centerIn: parent
|
||||
spacing: 6
|
||||
BusyIndicator {
|
||||
Spinner {
|
||||
visible: root.busy
|
||||
running: root.busy
|
||||
Layout.preferredWidth: 16
|
||||
Layout.preferredHeight: 16
|
||||
size: 14
|
||||
color: root.labelColor
|
||||
}
|
||||
Icon {
|
||||
visible: root.iconName !== "" && !root.busy
|
||||
name: root.iconName
|
||||
color: root.labelColor
|
||||
size: 16
|
||||
size: root.compact ? 15 : 16
|
||||
}
|
||||
Text {
|
||||
text: root.text
|
||||
color: root.labelColor
|
||||
font.pixelSize: 13
|
||||
font.pixelSize: root.compact ? theme.fontSmall : theme.fontBody
|
||||
font.weight: root.solid ? Font.DemiBold : Font.Medium
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
@@ -2,9 +2,18 @@ module BecUi
|
||||
Icon 1.0 Icon.qml
|
||||
IconButton 1.0 IconButton.qml
|
||||
TextButton 1.0 TextButton.qml
|
||||
Spinner 1.0 Spinner.qml
|
||||
StatusPill 1.0 StatusPill.qml
|
||||
Badge 1.0 Badge.qml
|
||||
Card 1.0 Card.qml
|
||||
Divider 1.0 Divider.qml
|
||||
Banner 1.0 Banner.qml
|
||||
EmptyState 1.0 EmptyState.qml
|
||||
LinearProgress 1.0 LinearProgress.qml
|
||||
FieldFrame 1.0 FieldFrame.qml
|
||||
InputField 1.0 InputField.qml
|
||||
SearchField 1.0 SearchField.qml
|
||||
SelectField 1.0 SelectField.qml
|
||||
SwitchField 1.0 SwitchField.qml
|
||||
SegmentedControl 1.0 SegmentedControl.qml
|
||||
FormField 1.0 FormField.qml
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Design tokens of the BEC UI kit, resolved from the active ``bec_qthemes`` theme.
|
||||
|
||||
One :class:`ThemeTokens` object holds every colour and metric the kit uses. The QML bridge
|
||||
(:class:`~bec_widgets.utils.quick.host.QmlTheme`) and the QWidget controls in
|
||||
:mod:`bec_widgets.utils.ux_kit` both read from it, so a widget looks the same in either
|
||||
technology and follows ``app.theme`` when the user switches between light and dark.
|
||||
|
||||
Base colours come straight from the theme XML of ``bec_qthemes`` (``BG``, ``CARD_BG``, ``FG``,
|
||||
``PRIMARY``, ``ACCENT_*`` ...). Everything else (muted text, hover fills, readable status text,
|
||||
status tints) is derived here, so there is exactly one place that defines those rules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtGui import QColor, QFontDatabase
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
# Copies of bec_qthemes' dark.xml and light.xml, used before a theme has been applied
|
||||
# (for example in unit tests or when a widget is created ahead of ``apply_theme``).
|
||||
FALLBACK_PALETTES: dict[str, dict[str, str]] = {
|
||||
"dark": {
|
||||
"BG": "#1e1f22",
|
||||
"CARD_BG": "#2b2c2f",
|
||||
"FIELD_BG": "#26272a",
|
||||
"FG": "#dddddd",
|
||||
"BORDER": "#464749",
|
||||
"PRIMARY": "#6089ef",
|
||||
"ON_PRIMARY": "#ffffff",
|
||||
"ACCENT_DEFAULT": "#40B6E0",
|
||||
"ACCENT_HIGHLIGHT": "#DB5860",
|
||||
"ACCENT_WARNING": "#EDA200",
|
||||
"ACCENT_EMERGENCY": "#E0534B",
|
||||
"ACCENT_SUCCESS": "#59A869",
|
||||
},
|
||||
"light": {
|
||||
"BG": "#f6f7fb",
|
||||
"CARD_BG": "#ffffff",
|
||||
"FIELD_BG": "#ffffff",
|
||||
"FG": "#151924",
|
||||
"BORDER": "#d9dde6",
|
||||
"PRIMARY": "#3b82f6",
|
||||
"ON_PRIMARY": "#ffffff",
|
||||
"ACCENT_DEFAULT": "#0a60ff",
|
||||
"ACCENT_HIGHLIGHT": "#B53565",
|
||||
"ACCENT_WARNING": "#EAC435",
|
||||
"ACCENT_EMERGENCY": "#CC181E",
|
||||
"ACCENT_SUCCESS": "#2CA58D",
|
||||
},
|
||||
}
|
||||
|
||||
# Status tones understood by StatusPill, Banner, LinearProgress, ... in both technologies.
|
||||
# The aliases keep the names used by the first ports (queue: busy/ok/warn/err/stale) working.
|
||||
TONE_ALIASES: dict[str, str] = {
|
||||
"busy": "info",
|
||||
"accent": "info",
|
||||
"ok": "success",
|
||||
"warn": "warning",
|
||||
"err": "danger",
|
||||
"error": "danger",
|
||||
"emergency": "danger",
|
||||
"critical": "danger",
|
||||
"stale": "subtle",
|
||||
"muted": "neutral",
|
||||
}
|
||||
|
||||
# Metrics shared by QML and QWidget controls, in device-independent pixels.
|
||||
METRICS: dict[str, int] = {
|
||||
"radiusSmall": 6,
|
||||
"radiusLarge": 10,
|
||||
"controlHeight": 32,
|
||||
"controlHeightCompact": 28,
|
||||
"spacing": 8,
|
||||
"padding": 12,
|
||||
"fontCaption": 11,
|
||||
"fontSmall": 12,
|
||||
"fontBody": 13,
|
||||
"fontTitle": 15,
|
||||
"fontHeadline": 20,
|
||||
"fontDisplay": 28,
|
||||
}
|
||||
|
||||
|
||||
def blend(base: QColor, top: QColor, amount: float) -> QColor:
|
||||
"""Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top), fully opaque."""
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
def readable_on(color: QColor, dark: bool) -> QColor:
|
||||
"""Return ``color`` adjusted so it stays legible as text on a card of the given theme.
|
||||
|
||||
Accent colours such as the light theme's yellow are too pale for text on white, so they are
|
||||
darkened; on dark cards accents are lifted slightly towards white.
|
||||
"""
|
||||
if dark:
|
||||
return blend(color, QColor("#ffffff"), 0.15)
|
||||
return blend(color, QColor("#000000"), 0.35 if color.lightnessF() > 0.5 else 0.1)
|
||||
|
||||
|
||||
def app_theme():
|
||||
"""Return ``QApplication.instance().theme`` (a ``bec_qthemes`` Theme) or None."""
|
||||
app = QApplication.instance()
|
||||
return getattr(app, "theme", None) if app is not None else None
|
||||
|
||||
|
||||
class ThemeTokens:
|
||||
"""Resolved colour and metric tokens of the active BEC theme.
|
||||
|
||||
Use :meth:`current` in paint code; it caches the tokens until the theme changes.
|
||||
|
||||
Args:
|
||||
theme_name(str | None): Force ``"dark"`` or ``"light"`` fallback colours. By default the
|
||||
application theme is used and the dark palette is the fallback.
|
||||
"""
|
||||
|
||||
_cache: tuple[object, ThemeTokens] | None = None
|
||||
|
||||
def __init__(self, theme_name: str | None = None):
|
||||
theme = app_theme() if theme_name is None else None
|
||||
name = theme_name or (getattr(theme, "theme", "dark") if theme is not None else "dark")
|
||||
fallback = FALLBACK_PALETTES.get(str(name), FALLBACK_PALETTES["dark"])
|
||||
|
||||
def color(key: str) -> QColor:
|
||||
default = fallback.get(key, "#808080")
|
||||
if theme is None:
|
||||
return QColor(default)
|
||||
return QColor(theme.color(key, default))
|
||||
|
||||
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.info = self.accent
|
||||
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)
|
||||
self.separator = self.track
|
||||
self.sunken = blend(self.bg, self.fg, 0.03 if self.dark else 0.015)
|
||||
|
||||
tint = 0.18 if self.dark else 0.12
|
||||
for tone in ("info", "success", "warning", "danger", "highlight"):
|
||||
base = getattr(self, tone)
|
||||
setattr(self, f"{tone}_text", readable_on(base, self.dark))
|
||||
setattr(self, f"{tone}_tint", blend(self.card, base, tint))
|
||||
|
||||
self.mono_family = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont).family()
|
||||
self.metrics = dict(METRICS)
|
||||
|
||||
@classmethod
|
||||
def current(cls) -> ThemeTokens:
|
||||
"""Return cached tokens for the active theme; rebuilt when the theme changes."""
|
||||
theme = app_theme()
|
||||
key = (id(theme), getattr(theme, "theme", None), id(getattr(theme, "_colors", None)))
|
||||
if cls._cache is None or cls._cache[0] != key:
|
||||
cls._cache = (key, cls())
|
||||
return cls._cache[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)
|
||||
|
||||
@staticmethod
|
||||
def tone_name(tone: str) -> str:
|
||||
"""Normalise a tone name: ``ok`` -> ``success``, ``err`` -> ``danger`` and so on."""
|
||||
tone = (tone or "neutral").lower()
|
||||
return TONE_ALIASES.get(tone, tone)
|
||||
|
||||
def tone(self, tone: str) -> QColor:
|
||||
"""Base colour of a tone (``neutral``, ``subtle``, ``primary``, ``info``, ``success``,
|
||||
``warning``, ``danger``, ``highlight`` or an alias)."""
|
||||
tone = self.tone_name(tone)
|
||||
if tone == "neutral":
|
||||
return self.fg_muted
|
||||
if tone == "subtle":
|
||||
return self.fg_subtle
|
||||
return QColor(getattr(self, tone, self.fg_muted))
|
||||
|
||||
def tone_text(self, tone: str) -> QColor:
|
||||
"""Text colour of a tone that stays readable on cards."""
|
||||
tone = self.tone_name(tone)
|
||||
if tone == "primary":
|
||||
return readable_on(self.primary, self.dark)
|
||||
return QColor(getattr(self, f"{tone}_text", self.tone(tone)))
|
||||
|
||||
def tone_tint(self, tone: str) -> QColor:
|
||||
"""Soft background fill of a tone; neutral tones use the hover fill."""
|
||||
tone = self.tone_name(tone)
|
||||
if tone in ("neutral", "subtle"):
|
||||
return self.hover
|
||||
if tone == "primary":
|
||||
return self.soft(self.primary, 0.18 if self.dark else 0.12)
|
||||
return QColor(getattr(self, f"{tone}_tint", self.hover))
|
||||
+1223
-106
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,318 @@
|
||||
"""Tests for the shared BEC UI kit: tokens, QML host and the QWidget controls."""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtQuickWidgets import QQuickWidget
|
||||
from qtpy.QtWidgets import QLineEdit, QWidget
|
||||
|
||||
from bec_widgets.examples.ui_kit_gallery.ui_kit_gallery import QML_GALLERY, QWidgetGallery
|
||||
from bec_widgets.tests.utils import create_widget
|
||||
from bec_widgets.utils.colors import apply_theme
|
||||
from bec_widgets.utils.quick import (
|
||||
QML_IMPORT_PATH,
|
||||
MaterialIconProvider,
|
||||
QmlTheme,
|
||||
ThemeTokens,
|
||||
create_quick_widget,
|
||||
quick_theme,
|
||||
release_quick_widget,
|
||||
)
|
||||
from bec_widgets.utils.quick.tokens import FALLBACK_PALETTES
|
||||
from bec_widgets.utils.ux_kit import (
|
||||
Badge,
|
||||
Banner,
|
||||
Card,
|
||||
FormField,
|
||||
IconButton,
|
||||
LinearProgress,
|
||||
SearchField,
|
||||
SegmentedControl,
|
||||
Spinner,
|
||||
StatusPill,
|
||||
TextButton,
|
||||
refresh_kit_theme,
|
||||
)
|
||||
|
||||
BEC_UI = QML_IMPORT_PATH / "BecUi"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dark_theme():
|
||||
apply_theme("dark")
|
||||
yield
|
||||
apply_theme("light")
|
||||
|
||||
|
||||
def test_fallback_palettes_match_bec_qthemes():
|
||||
from bec_qthemes._main import read_theme_xml
|
||||
from bec_qthemes.qss_editor.qss_editor import THEMES_PATH
|
||||
|
||||
for name, palette in FALLBACK_PALETTES.items():
|
||||
_, mapping = read_theme_xml(THEMES_PATH / f"{name}.xml")
|
||||
for key, value in palette.items():
|
||||
assert QColor(mapping[key]) == QColor(value), (name, key)
|
||||
|
||||
|
||||
def test_tokens_follow_application_theme(dark_theme):
|
||||
dark = ThemeTokens()
|
||||
assert dark.name == "dark" and dark.dark
|
||||
apply_theme("light")
|
||||
light = ThemeTokens()
|
||||
assert light.name == "light" and not light.dark
|
||||
assert light.card != dark.card
|
||||
assert ThemeTokens.current().card == light.card
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"alias, canonical",
|
||||
[
|
||||
("ok", "success"),
|
||||
("warn", "warning"),
|
||||
("err", "danger"),
|
||||
("busy", "info"),
|
||||
("stale", "subtle"),
|
||||
],
|
||||
)
|
||||
def test_tone_aliases(alias, canonical):
|
||||
tokens = ThemeTokens()
|
||||
assert tokens.tone(alias) == tokens.tone(canonical)
|
||||
assert tokens.tone_text(alias) == tokens.tone_text(canonical)
|
||||
assert tokens.tone_tint(alias) == tokens.tone_tint(canonical)
|
||||
|
||||
|
||||
def test_light_warning_text_is_darker_than_accent():
|
||||
tokens = ThemeTokens()
|
||||
assert tokens.warning_text.lightnessF() < tokens.warning.lightnessF()
|
||||
|
||||
|
||||
def test_qml_theme_aliases_match_canonical_names(dark_theme):
|
||||
theme = QmlTheme()
|
||||
pairs = [
|
||||
("muted", "fgMuted"),
|
||||
("faint", "fgSubtle"),
|
||||
("text", "fg"),
|
||||
("foreground", "fg"),
|
||||
("background", "bg"),
|
||||
("busy", "info"),
|
||||
("ok", "success"),
|
||||
("warn", "warning"),
|
||||
("err", "danger"),
|
||||
("emergency", "danger"),
|
||||
("errText", "dangerText"),
|
||||
("okTint", "successTint"),
|
||||
("window", "bg"),
|
||||
("base", "field"),
|
||||
("button", "card"),
|
||||
("onAccent", "onPrimary"),
|
||||
]
|
||||
for alias, canonical in pairs:
|
||||
assert theme.property(alias) == theme.property(canonical), alias
|
||||
assert theme.property("isDark") is True
|
||||
assert theme.property("radiusLarge") == 10
|
||||
assert theme.toneText("warn") == theme.property("warningText")
|
||||
assert QColor(theme.property("c")["CARD_BG"]) == theme.property("card")
|
||||
|
||||
|
||||
def test_qml_theme_emits_changed_on_theme_switch(qtbot):
|
||||
theme = quick_theme()
|
||||
with qtbot.waitSignal(theme.changed):
|
||||
apply_theme("dark")
|
||||
assert theme.property("dark") is True
|
||||
apply_theme("light")
|
||||
assert theme.property("dark") is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("color", ["%23ff0000", "ff0000", "red"])
|
||||
def test_icon_provider_accepts_colour_forms(color):
|
||||
provider = MaterialIconProvider()
|
||||
size = QSize()
|
||||
pixmap = provider.requestPixmap(f"check?color={color}&filled=1", size, QSize(20, 20))
|
||||
assert not pixmap.isNull()
|
||||
assert size.width() == pixmap.width()
|
||||
|
||||
|
||||
def test_icon_provider_unknown_icon_returns_empty_pixmap():
|
||||
provider = MaterialIconProvider()
|
||||
assert provider.requestPixmap("no_such_icon_xyz", QSize(), QSize(20, 20)).isNull()
|
||||
|
||||
|
||||
def test_qmldir_lists_every_control():
|
||||
listed = {
|
||||
line.split()[0]
|
||||
for line in (BEC_UI / "qmldir").read_text().splitlines()
|
||||
if line and not line.startswith("module")
|
||||
}
|
||||
files = {path.stem for path in BEC_UI.glob("*.qml")}
|
||||
assert listed == files
|
||||
|
||||
|
||||
def test_gallery_loads_every_control_without_errors(qtbot):
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
view = create_quick_widget(host, QML_GALLERY, raise_on_error=True)
|
||||
assert view.status() == QQuickWidget.Status.Ready
|
||||
source = QML_GALLERY.read_text()
|
||||
for path in BEC_UI.glob("*.qml"):
|
||||
if path.stem in ("FieldFrame", "Icon"): # used inside every other control
|
||||
continue
|
||||
assert f"{path.stem} {{" in source, f"{path.stem} missing from the gallery"
|
||||
release_quick_widget(view)
|
||||
|
||||
|
||||
def test_create_quick_widget_with_context_uses_own_engine(qtbot, tmp_path: Path):
|
||||
qml = tmp_path / "View.qml"
|
||||
qml.write_text(
|
||||
"import QtQuick\nimport BecUi\nRectangle { color: theme.card; "
|
||||
"property string label: backend.objectName; StatusPill { text: 'x'; tone: 'ok' } }\n"
|
||||
)
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
backend = QWidget()
|
||||
backend.setObjectName("demo")
|
||||
view = create_quick_widget(host, qml, context={"backend": backend}, raise_on_error=True)
|
||||
assert view.rootObject().property("label") == "demo"
|
||||
shared = create_quick_widget(host, qml.with_name("Plain.qml"), raise_on_error=False)
|
||||
assert view.engine() is not shared.engine()
|
||||
release_quick_widget(view)
|
||||
release_quick_widget(shared)
|
||||
backend.deleteLater()
|
||||
|
||||
|
||||
def test_create_quick_widget_raises_on_error(qtbot, tmp_path: Path):
|
||||
qml = tmp_path / "Broken.qml"
|
||||
qml.write_text("import QtQuick\nRectangle { nonsense: }\n")
|
||||
host = QWidget()
|
||||
qtbot.addWidget(host)
|
||||
with pytest.raises(RuntimeError):
|
||||
create_quick_widget(host, qml, raise_on_error=True)
|
||||
|
||||
|
||||
def test_qwidget_gallery_builds(qtbot):
|
||||
gallery = create_widget(qtbot, QWidgetGallery)
|
||||
assert gallery.findChildren(Card)
|
||||
for spinner in gallery.findChildren(Spinner):
|
||||
spinner.cleanup()
|
||||
for button in gallery.findChildren(TextButton):
|
||||
button.set_busy(False)
|
||||
for bar in gallery.findChildren(LinearProgress):
|
||||
bar.cleanup()
|
||||
|
||||
|
||||
def test_controls_follow_theme_switch(qtbot):
|
||||
card = create_widget(qtbot, Card, "Title")
|
||||
light_qss = card.styleSheet()
|
||||
apply_theme("dark")
|
||||
try:
|
||||
assert card.styleSheet() != light_qss
|
||||
assert ThemeTokens.current().card.name() in card.styleSheet()
|
||||
finally:
|
||||
apply_theme("light")
|
||||
|
||||
|
||||
def test_refresh_kit_theme_reaches_children(qtbot):
|
||||
card = create_widget(qtbot, Card, "Title")
|
||||
button = TextButton("Go", "primary", parent=card)
|
||||
card.body.addWidget(button)
|
||||
button.setStyleSheet("")
|
||||
refresh_kit_theme(card)
|
||||
assert "QPushButton" in button.styleSheet()
|
||||
|
||||
|
||||
def test_text_button_busy_and_variants(qtbot):
|
||||
button = create_widget(qtbot, TextButton, "Submit", "primary")
|
||||
button.set_busy(True)
|
||||
assert button.busy
|
||||
button.set_busy(False)
|
||||
for variant in ("danger", "success", "neutral", "dangerOutline", "ghost"):
|
||||
button.set_variant(variant)
|
||||
assert "QPushButton" in button.styleSheet()
|
||||
|
||||
|
||||
def test_icon_button_danger_and_checked(qtbot):
|
||||
button = create_widget(qtbot, IconButton, "delete", "Remove", danger=True)
|
||||
assert button.accessibleName() == "Remove"
|
||||
button.setCheckable(True)
|
||||
button.setChecked(True)
|
||||
assert not button.icon().isNull()
|
||||
|
||||
|
||||
def test_status_pill_named_and_colour_tones(qtbot):
|
||||
pill = create_widget(qtbot, StatusPill, text="Running", tone="busy", pulse=True)
|
||||
assert pill.pulsing
|
||||
pill.set_status("Failed", QColor("red"), icon_name="error")
|
||||
assert not pill.pulsing
|
||||
assert pill.text == "Failed"
|
||||
assert pill.sizeHint().width() > 30
|
||||
pill.cleanup()
|
||||
|
||||
|
||||
def test_badge_label_and_visibility(qtbot):
|
||||
badge = create_widget(qtbot, Badge, count=150)
|
||||
assert badge.label == "99+"
|
||||
badge.set_count(0)
|
||||
assert not badge.isVisible()
|
||||
|
||||
|
||||
def test_linear_progress_clamps(qtbot):
|
||||
bar = create_widget(qtbot, LinearProgress)
|
||||
bar.set_value(1.5, animate=False)
|
||||
assert bar.value == 1.0
|
||||
bar.set_indeterminate(True)
|
||||
bar.set_indeterminate(False)
|
||||
bar.cleanup()
|
||||
|
||||
|
||||
def test_banner_close_and_action(qtbot):
|
||||
banner = create_widget(
|
||||
qtbot, Banner, "err", "Title", "Text", action_text="Retry", closable=True
|
||||
)
|
||||
with qtbot.waitSignal(banner.action_triggered):
|
||||
qtbot.mouseClick(banner.action_button, Qt.MouseButton.LeftButton)
|
||||
with qtbot.waitSignal(banner.closed):
|
||||
qtbot.mouseClick(banner.close_button, Qt.MouseButton.LeftButton)
|
||||
assert not banner.isVisible()
|
||||
|
||||
|
||||
def test_form_field_error_marks_control_invalid(qtbot):
|
||||
edit = QLineEdit()
|
||||
field = create_widget(qtbot, FormField, "Steps", edit, required=True, helper="At least 2")
|
||||
assert field.message.text() == "At least 2"
|
||||
field.set_error("Must be at least 2")
|
||||
assert edit.property("invalid") is True
|
||||
assert field.message.text() == "Must be at least 2"
|
||||
field.set_error("")
|
||||
assert edit.property("invalid") is False
|
||||
assert field.message.text() == "At least 2"
|
||||
|
||||
|
||||
def test_search_field_escape_clears(qtbot):
|
||||
field = create_widget(qtbot, SearchField, shortcut_hint="Ctrl K")
|
||||
field.setText("samx")
|
||||
with qtbot.waitSignal(field.cleared):
|
||||
qtbot.keyClick(field, Qt.Key.Key_Escape)
|
||||
assert field.text() == ""
|
||||
|
||||
|
||||
def test_segmented_control_emits_on_click_only(qtbot):
|
||||
control = create_widget(qtbot, SegmentedControl, ["All", {"text": "Errors", "count": 2}])
|
||||
control.set_current_index(1)
|
||||
assert control.current_index == 1
|
||||
with qtbot.waitSignal(control.activated) as blocker:
|
||||
qtbot.mouseClick(control._buttons[0], Qt.MouseButton.LeftButton)
|
||||
assert blocker.args == [0]
|
||||
control.set_count(1, 5)
|
||||
assert "5" in control._buttons[1].text()
|
||||
|
||||
|
||||
def test_collapsible_card_toggles_body(qtbot):
|
||||
card = create_widget(qtbot, Card, "Section", collapsible=True, title_style="caption")
|
||||
assert card.title_label.text() == "SECTION"
|
||||
card.set_expanded(False)
|
||||
assert card.body_widget.isHidden()
|
||||
card.set_expanded(True)
|
||||
assert not card.body_widget.isHidden()
|
||||
Reference in new issue
Block a user