feat(quick): unify the QML and QWidget UI kit with shared tokens, new controls, gallery and docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KYuz59BEmcx66GwNT3cmL
This commit is contained in:
Claude committed 2026-10-09 21:48:31 +00:00
1 parent 7b1b5c70e2
commit 6261cf304b
27 files changed
+3369 -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,371 @@
"""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:
if hasattr(widget, "view"):
widget.view.grabFramebuffer().save(str(path))
else:
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())
+132
View File
@@ -0,0 +1,132 @@
# 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`.
New code uses the canonical names.
## 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.
+13 -4
View File
@@ -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",
]
+246 -106
View File
@@ -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,63 @@ 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
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`` ...), 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 +81,137 @@ 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")
# 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 +219,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 +242,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()
}
}
}
+40 -9
View File
@@ -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
}
+9
View File
@@ -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
+209
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+313
View File
@@ -0,0 +1,313 @@
"""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"),
]
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")
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()