diff --git a/bec_widgets/examples/ui_kit_gallery/UiKitGallery.qml b/bec_widgets/examples/ui_kit_gallery/UiKitGallery.qml new file mode 100644 index 00000000..e0b24754 --- /dev/null +++ b/bec_widgets/examples/ui_kit_gallery/UiKitGallery.qml @@ -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 } + } + } +} diff --git a/bec_widgets/examples/ui_kit_gallery/__init__.py b/bec_widgets/examples/ui_kit_gallery/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/examples/ui_kit_gallery/ui_kit_gallery.py b/bec_widgets/examples/ui_kit_gallery/ui_kit_gallery.py new file mode 100644 index 00000000..776e0a23 --- /dev/null +++ b/bec_widgets/examples/ui_kit_gallery/ui_kit_gallery.py @@ -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()) diff --git a/bec_widgets/utils/quick/README.md b/bec_widgets/utils/quick/README.md new file mode 100644 index 00000000..4115e925 --- /dev/null +++ b/bec_widgets/utils/quick/README.md @@ -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.` 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_*`) | +| `Text` | `_text` | status colour adjusted to stay readable as text on cards | +| `Tint` | `_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/` | +| `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/?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 `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/.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. diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py index dcc683f1..78a7ea0a 100644 --- a/bec_widgets/utils/quick/__init__.py +++ b/bec_widgets/utils/quick/__init__.py @@ -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", ] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py index 0df3b172..daa39f3f 100644 --- a/bec_widgets/utils/quick/host.py +++ b/bec_widgets/utils/quick/host.py @@ -5,6 +5,9 @@ application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app. ``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial properties of the root item, typically a single ``backend`` QObject. + +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.`` and follow theme changes.""" + """Expose the BEC theme to QML as ``theme.`` 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, ``Text`` (readable on cards) and ``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/?color=%23rrggbb&filled=1``.""" + """Serve Material icons to QML as ``image://material/?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 diff --git a/bec_widgets/utils/quick/qml/BecUi/Badge.qml b/bec_widgets/utils/quick/qml/BecUi/Badge.qml new file mode 100644 index 00000000..c8facacb --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Badge.qml @@ -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 + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Banner.qml b/bec_widgets/utils/quick/qml/BecUi/Banner.qml new file mode 100644 index 00000000..494aaeb0 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Banner.qml @@ -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() + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml index 2451958f..14eb0d65 100644 --- a/bec_widgets/utils/quick/qml/BecUi/Card.qml +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -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 + } + } } diff --git a/bec_widgets/utils/quick/qml/BecUi/Divider.qml b/bec_widgets/utils/quick/qml/BecUi/Divider.qml new file mode 100644 index 00000000..b0030164 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Divider.qml @@ -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 +} diff --git a/bec_widgets/utils/quick/qml/BecUi/EmptyState.qml b/bec_widgets/utils/quick/qml/BecUi/EmptyState.qml new file mode 100644 index 00000000..843c3228 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/EmptyState.qml @@ -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() + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml index aad88d6a..ca21afe8 100644 --- a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -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 diff --git a/bec_widgets/utils/quick/qml/BecUi/FormField.qml b/bec_widgets/utils/quick/qml/BecUi/FormField.qml new file mode 100644 index 00000000..82ce9cf1 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FormField.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml index 9603aa81..9ff17de4 100644 --- a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -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 diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml index 0ed06d5f..b4fa82d0 100644 --- a/bec_widgets/utils/quick/qml/BecUi/InputField.qml +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -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 diff --git a/bec_widgets/utils/quick/qml/BecUi/LinearProgress.qml b/bec_widgets/utils/quick/qml/BecUi/LinearProgress.qml new file mode 100644 index 00000000..130b4223 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/LinearProgress.qml @@ -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 + } + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SearchField.qml b/bec_widgets/utils/quick/qml/BecUi/SearchField.qml new file mode 100644 index 00000000..bd5f33ba --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SearchField.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SegmentedControl.qml b/bec_widgets/utils/quick/qml/BecUi/SegmentedControl.qml new file mode 100644 index 00000000..ff1fd434 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SegmentedControl.qml @@ -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) } + } + } + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml index 2d4f814b..88019443 100644 --- a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -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 } } } diff --git a/bec_widgets/utils/quick/qml/BecUi/Spinner.qml b/bec_widgets/utils/quick/qml/BecUi/Spinner.qml new file mode 100644 index 00000000..0ca20833 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Spinner.qml @@ -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 + } + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml index 7901c832..9b7b56d3 100644 --- a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -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 } } diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml index a21f3221..9d7faf4a 100644 --- a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -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 } } diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml index 45b069dc..afe3279b 100644 --- a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -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 } diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir index 160907bc..9eec0ce5 100644 --- a/bec_widgets/utils/quick/qml/BecUi/qmldir +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -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 diff --git a/bec_widgets/utils/quick/tokens.py b/bec_widgets/utils/quick/tokens.py new file mode 100644 index 00000000..9336ac12 --- /dev/null +++ b/bec_widgets/utils/quick/tokens.py @@ -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)) diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py index c8497afc..9b08ac4f 100644 --- a/bec_widgets/utils/ux_kit.py +++ b/bec_widgets/utils/ux_kit.py @@ -1,15 +1,33 @@ -"""Small themed QWidget controls used by the QWidget twins of the QML widget ports. +"""QWidget controls of the BEC UI kit. -They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and -QWidget versions of a widget look and behave the same and can be compared side by side. Colours -come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`. +They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml/BecUi`` one to one, so +the QML and QWidget versions of a widget look and behave the same. Colours and metrics come from +the same :class:`~bec_widgets.utils.quick.tokens.ThemeTokens`. + +Every control follows ``app.theme`` by itself: when the user switches between light and dark, +its ``refresh_theme(tokens)`` method runs. :func:`refresh_kit_theme` re-themes a whole subtree +by hand, for controls created before a theme was applied. + +See ``bec_widgets/utils/quick/README.md`` for the component reference. """ from __future__ import annotations +from typing import Sequence + from bec_qthemes import material_icon -from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt -from qtpy.QtGui import QColor, QFont, QPainter +from qtpy.QtCore import ( + Property, + QEasingCurve, + QPropertyAnimation, + QRectF, + QSize, + Qt, + QTimer, + QVariantAnimation, + Signal, +) +from qtpy.QtGui import QColor, QFont, QKeySequence, QPainter, QPen, QPixmap from qtpy.QtWidgets import ( QFrame, QHBoxLayout, @@ -22,7 +40,31 @@ from qtpy.QtWidgets import ( QWidget, ) -from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.quick.tokens import ThemeTokens, app_theme + +__all__ = [ + "Badge", + "Banner", + "Card", + "Divider", + "EmptyState", + "FormField", + "IconButton", + "LinearProgress", + "PortedPropertiesMixin", + "SearchField", + "SegmentedControl", + "Spinner", + "StatusPill", + "SuffixLineEdit", + "TextButton", + "ThemeTokens", + "ToggleSwitch", + "field_qss", + "refresh_kit_theme", + "rgba", + "set_invalid", +] def rgba(color: QColor, alpha: float) -> str: @@ -32,14 +74,43 @@ def rgba(color: QColor, alpha: float) -> str: def refresh_kit_theme(root: QWidget) -> ThemeTokens: """Re-theme every kit control below ``root`` and return the current tokens.""" - tokens = ThemeTokens() - for widget in [root, *root.findChildren(QWidget)]: + tokens = ThemeTokens.current() + for widget in root.findChildren(QWidget): refresh = getattr(widget, "refresh_theme", None) - if callable(refresh) and widget is not root: + if callable(refresh): refresh(tokens) return tokens +def _tone_color(tokens: ThemeTokens, tone: QColor | str) -> QColor: + """Text colour for a tone given by name or as a colour.""" + if isinstance(tone, str): + return tokens.tone_text(tone) + return QColor(tone) + + +def _px_font(widget: QWidget, size: int, weight: QFont.Weight | None = None) -> QFont: + font = QFont(widget.font()) + font.setPixelSize(size) + if weight is not None: + font.setWeight(weight) + return font + + +class _ThemeFollower: + """Mixin: call ``refresh_theme(tokens)`` whenever ``app.theme`` changes.""" + + def _follow_theme(self) -> None: + theme = app_theme() + if theme is not None: + theme.theme_changed.connect(self._on_app_theme_changed) + + def _on_app_theme_changed(self, *_args) -> None: + refresh = getattr(self, "refresh_theme", None) + if callable(refresh): + refresh(ThemeTokens.current()) + + def field_qss(tokens: ThemeTokens) -> str: """Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error.""" return f""" @@ -47,10 +118,10 @@ def field_qss(tokens: ThemeTokens) -> str: background: {tokens.field.name()}; color: {tokens.fg.name()}; border: 1px solid {tokens.border.name()}; - border-radius: 6px; + border-radius: {tokens.metrics["radiusSmall"]}px; padding: 0px 8px; min-height: 30px; - font-size: 13px; + font-size: {tokens.metrics["fontBody"]}px; selection-background-color: {tokens.primary.name()}; }} QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ @@ -60,7 +131,8 @@ def field_qss(tokens: ThemeTokens) -> str: border: 2px solid {tokens.primary.name()}; padding: 0px 7px; }} - QLineEdit[invalid="true"], QComboBox[invalid="true"] {{ + QLineEdit[invalid="true"], QComboBox[invalid="true"], + QSpinBox[invalid="true"], QDoubleSpinBox[invalid="true"] {{ border: 2px solid {tokens.danger.name()}; padding: 0px 7px; }} @@ -78,41 +150,127 @@ def set_invalid(widget: QWidget, invalid: bool) -> None: widget.style().polish(widget) -class IconButton(QToolButton): - """Flat square icon button with hover feedback, matching ``BecUi.IconButton``.""" +def _spinner_pixmap(size: int, color: QColor, angle: float, ratio: float = 2.0) -> QPixmap: + pixmap = QPixmap(int(size * ratio), int(size * ratio)) + pixmap.setDevicePixelRatio(ratio) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + _paint_spinner(painter, QRectF(0, 0, size, size), color, angle) + painter.end() + return pixmap - def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18): + +def _paint_spinner(painter: QPainter, rect: QRectF, color: QColor, angle: float) -> None: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + width = max(1.5, rect.width() / 8) + arc = rect.adjusted(width / 2, width / 2, -width / 2, -width / 2) + track = QColor(color) + track.setAlphaF(0.2) + painter.setPen(QPen(track, width)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(arc) + pen = QPen(color, width) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawArc(arc, int((90 - angle) * 16), int(-100 * 16)) + + +# -------------------------------------------------------------------------------------------- +# Buttons +# -------------------------------------------------------------------------------------------- + + +class IconButton(_ThemeFollower, QToolButton): + """Flat square icon button with hover feedback, matching ``BecUi.IconButton``. + + The tip is required in practice: it is the tooltip and the accessible name. + + Args: + icon_name(str): Material icon name. + tip(str): Tooltip and accessible name. + parent(QWidget | None): Parent widget. + size(int): Icon size in pixels. + danger(bool): Tint the icon red, for destructive actions. + compact(bool): 28 px instead of 30 px. + """ + + def __init__( + self, + icon_name: str, + tip: str = "", + parent: QWidget | None = None, + size: int = 18, + danger: bool = False, + compact: bool = False, + ): super().__init__(parent) self._icon_name = icon_name - self._icon_size = size + self._danger = danger self.setToolTip(tip) self.setAccessibleName(tip) self.setAutoRaise(True) - self.setFixedSize(30, 30) + side = 28 if compact else 30 + self.setFixedSize(side, side) self.setIconSize(QSize(size, size)) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.refresh_theme(ThemeTokens()) + self.toggled.connect(lambda _checked: self.refresh_theme(ThemeTokens.current())) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() def set_icon_name(self, icon_name: str) -> None: """Change the Material icon.""" self._icon_name = icon_name - self.refresh_theme(ThemeTokens()) + self.refresh_theme(ThemeTokens.current()) + + def set_danger(self, danger: bool) -> None: + """Tint the icon red.""" + self._danger = danger + self.refresh_theme(ThemeTokens.current()) def refresh_theme(self, tokens: ThemeTokens) -> None: """Apply the theme tokens.""" - color = tokens.primary if self.isChecked() else tokens.fg_muted + if self.isChecked(): + color = tokens.primary + elif self._danger: + color = tokens.danger_text + else: + color = tokens.fg_muted self.setIcon( - material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False) + material_icon( + self._icon_name, + size=(48, 48), + color=color, + filled=self.isChecked(), + convert_to_pixmap=False, + ) ) + hover = tokens.danger_tint if self._danger else tokens.hover + radius = tokens.metrics["radiusSmall"] self.setStyleSheet( - f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" - f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + f"QToolButton {{ border: none; border-radius: {radius}px; background: transparent; }}" + f"QToolButton:hover {{ background: {hover.name()}; }}" + f"QToolButton:checked {{ background: {tokens.hover.name()}; }}" f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}" + f"QToolButton:focus {{ border: 2px solid {tokens.primary.name()}; }}" + f"QToolButton:disabled {{ background: transparent; }}" ) -class TextButton(QPushButton): - """Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``.""" +class TextButton(_ThemeFollower, QPushButton): + """Labelled button, matching ``BecUi.TextButton``. + + Variants: ``primary``, ``danger``, ``success`` (solid), ``neutral`` (outlined), + ``dangerOutline`` (red outline) and ``ghost`` (no border). + + Args: + text(str): Label. + variant(str): Colour variant. + icon_name(str): Optional Material icon shown before the label. + parent(QWidget | None): Parent widget. + compact(bool): 28 px high with a 12 px label. + """ + + SOLID = ("primary", "danger", "success") def __init__( self, @@ -120,60 +278,198 @@ class TextButton(QPushButton): variant: str = "neutral", icon_name: str = "", parent: QWidget | None = None, + compact: bool = False, ): super().__init__(text, parent) self._variant = variant self._icon_name = icon_name - self.setMinimumHeight(32) + self._compact = compact + self._busy = False + self._busy_angle = 0.0 + self._busy_timer = QTimer(self) + self._busy_timer.setInterval(40) + self._busy_timer.timeout.connect(self._advance_busy) + self.setMinimumHeight(28 if compact else 32) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.refresh_theme(ThemeTokens()) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() def set_variant(self, variant: str) -> None: """Switch the colour variant.""" self._variant = variant - self.refresh_theme(ThemeTokens()) + self.refresh_theme(ThemeTokens.current()) + + def set_icon_name(self, icon_name: str) -> None: + """Change the Material icon.""" + self._icon_name = icon_name + self.refresh_theme(ThemeTokens.current()) + + def set_busy(self, busy: bool) -> None: + """Show a spinner instead of the icon, e.g. while a request is in flight.""" + self._busy = busy + if busy and self.isVisible(): + self._busy_timer.start() + elif not busy: + self._busy_timer.stop() + self.refresh_theme(ThemeTokens.current()) + + def showEvent(self, event): # pylint: disable=invalid-name + super().showEvent(event) + if self._busy: + self._busy_timer.start() + + def hideEvent(self, event): # pylint: disable=invalid-name + super().hideEvent(event) + self._busy_timer.stop() + + @property + def busy(self) -> bool: + """Whether the spinner is shown.""" + return self._busy + + def _label_color(self, tokens: ThemeTokens) -> QColor: + if self._variant == "primary": + return tokens.on_primary + if self._variant in self.SOLID: + return QColor("white") + if self._variant == "dangerOutline": + return tokens.danger_text + return tokens.fg + + def _advance_busy(self) -> None: + self._busy_angle = (self._busy_angle + 16) % 360 + size = 14 + self.setIcon( + _spinner_pixmap(size, self._label_color(ThemeTokens.current()), self._busy_angle) + ) + self.setIconSize(QSize(size, size)) def refresh_theme(self, tokens: ThemeTokens) -> None: """Apply the theme tokens.""" + radius = tokens.metrics["radiusSmall"] + font_px = tokens.metrics["fontSmall" if self._compact else "fontBody"] + pad = 9 if self._compact else 12 + label = self._label_color(tokens) base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get( self._variant ) if base is not None: - label = QColor("white") qss = ( - f"QPushButton {{ background: {base.name()}; color: white; border: none;" - f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}" + f"QPushButton {{ background: {base.name()}; color: {label.name()}; border: none;" + f" border-radius: {radius}px; padding: 0 {pad}px; font-size: {font_px}px;" + f" font-weight: 600; }}" f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}" f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}" + f"QPushButton:focus {{ border: 2px solid {tokens.primary.lighter(140).name()}; }}" f"QPushButton:disabled {{ background: {rgba(base, 0.45)};" f" color: {rgba(label, 0.7)}; }}" ) else: - label = tokens.fg - border = tokens.border.name() if self._variant == "neutral" else "transparent" + if self._variant == "neutral": + border = tokens.border.name() + elif self._variant == "dangerOutline": + border = rgba(tokens.danger, 0.6) + else: + border = "transparent" + hover = tokens.danger_tint if self._variant == "dangerOutline" else tokens.hover qss = ( - f"QPushButton {{ background: transparent; color: {tokens.fg.name()};" - f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;" - f" font-size: 13px; font-weight: 500; }}" - f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton {{ background: transparent; color: {label.name()};" + f" border: 1px solid {border}; border-radius: {radius}px; padding: 0 {pad}px;" + f" font-size: {font_px}px; font-weight: 500; }}" + f"QPushButton:hover {{ background: {hover.name()}; }}" f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}" - f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}" + f"QPushButton:focus {{ border: 2px solid {tokens.primary.name()}; }}" + f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()};" + f" border-color: {tokens.track.name()}; }}" ) self.setStyleSheet(qss) - if self._icon_name: + if self._busy: + self._advance_busy() + elif self._icon_name: self.setIcon( material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False) ) - self.setIconSize(QSize(16, 16)) + self.setIconSize(QSize(15, 15) if self._compact else QSize(16, 16)) + else: + self.setIcon(QPixmap()) -class StatusPill(QWidget): - """Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``.""" +# -------------------------------------------------------------------------------------------- +# Status +# -------------------------------------------------------------------------------------------- - def __init__(self, parent: QWidget | None = None): + +class Spinner(_ThemeFollower, QWidget): + """Small indeterminate spinner, matching ``BecUi.Spinner``.""" + + def __init__(self, parent: QWidget | None = None, size: int = 16, color: QColor | None = None): + super().__init__(parent) + self._color = color + self._angle = 0.0 + self.setFixedSize(size, size) + self._timer = QTimer(self) + self._timer.setInterval(30) + self._timer.timeout.connect(self._advance) + self._running = True + self._follow_theme() + + def showEvent(self, event): # pylint: disable=invalid-name + super().showEvent(event) + if self._running: + self._timer.start() + + def hideEvent(self, event): # pylint: disable=invalid-name + super().hideEvent(event) + self._timer.stop() + + def _advance(self) -> None: + self._angle = (self._angle + 12) % 360 + self.update() + + def set_running(self, running: bool) -> None: + """Start or stop the animation; a stopped spinner hides itself.""" + self._running = running + self.setVisible(running) + if not running: + self._timer.stop() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new primary colour.""" + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + color = self._color or ThemeTokens.current().primary + _paint_spinner(painter, QRectF(self.rect()), color, self._angle) + painter.end() + + def cleanup(self) -> None: + """Stop the animation timer.""" + self._timer.stop() + + +class StatusPill(_ThemeFollower, QWidget): + """Rounded status chip with a coloured dot or icon, matching ``BecUi.StatusPill``. + + The tone is a name (``neutral``, ``info``, ``success``, ``warning``, ``danger``, ``subtle`` + or an alias such as ``ok``/``err``) or a colour. + """ + + def __init__( + self, + parent: QWidget | None = None, + text: str = "", + tone: QColor | str = "neutral", + pulse: bool = False, + icon_name: str = "", + outlined: bool = False, + ): super().__init__(parent) self._text = "" - self._tone = QColor("#808080") + self._tone: QColor | str = "neutral" + self._icon_name = "" + self._outlined = outlined + self._icon_pixmap = QPixmap() self._dot_opacity = 1.0 self._pulse = QPropertyAnimation(self, b"dot_opacity", self) self._pulse.setDuration(1100) @@ -183,22 +479,29 @@ class StatusPill(QWidget): self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad) self._pulse.setLoopCount(-1) self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - font = self.font() - font.setPixelSize(12) - font.setWeight(QFont.Weight.DemiBold) - self.setFont(font) + self.setFont(_px_font(self, 12, QFont.Weight.DemiBold)) + self.set_status(text, tone, pulse, icon_name) + self._follow_theme() - def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None: - """Set label, colour and whether the dot pulses.""" + def set_status( + self, text: str, tone: QColor | str, pulse: bool = False, icon_name: str = "" + ) -> None: + """Set label, tone, whether the dot pulses and an optional icon replacing the dot.""" self._text = text - self._tone = QColor(tone) + self._tone = tone if isinstance(tone, str) else QColor(tone) + self._icon_name = icon_name if pulse and self._pulse.state() != QPropertyAnimation.State.Running: self._pulse.start() elif not pulse: self._pulse.stop() self._dot_opacity = 1.0 self.setToolTip(text) - self.updateGeometry() + self.setAccessibleName(text) + self.refresh_theme(ThemeTokens.current()) + + def set_outlined(self, outlined: bool) -> None: + """Quiet look: no fill, hairline border.""" + self._outlined = outlined self.update() @property @@ -220,24 +523,54 @@ class StatusPill(QWidget): dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity) + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Re-resolve a named tone and repaint.""" + color = _tone_color(tokens, self._tone) + self._icon_pixmap = ( + material_icon(self._icon_name, size=(28, 28), color=color, convert_to_pixmap=True) + if self._icon_name + else QPixmap() + ) + self.updateGeometry() + self.update() + def sizeHint(self) -> QSize: # pylint: disable=invalid-name - return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22) + extra = 30 if self._icon_name else 26 + return QSize(self.fontMetrics().horizontalAdvance(self._text) + extra, 22) def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens.current() + tone = _tone_color(tokens, self._tone) painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = QRectF(self.rect()) - background = QColor(self._tone) - background.setAlphaF(0.16) - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(background) - painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) - dot = QColor(self._tone) - dot.setAlphaF(self._dot_opacity) - painter.setBrush(dot) - painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) - painter.setPen(self._tone) - painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text) + radius = rect.height() / 2 + if self._outlined: + painter.setPen(QPen(tokens.separator, 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), radius, radius) + else: + background = QColor(tone) + background.setAlphaF(0.18 if tokens.dark else 0.13) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(background) + painter.drawRoundedRect(rect, radius, radius) + if self._icon_name: + painter.drawPixmap( + QRectF(7, rect.height() / 2 - 7, 14, 14), self._icon_pixmap, QRectF() + ) + text_left = 25 + else: + dot = QColor(tone) + dot.setAlphaF(self._dot_opacity) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(dot) + painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) + text_left = 22 + painter.setPen(tone) + painter.drawText( + rect.adjusted(text_left, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text + ) painter.end() def cleanup(self) -> None: @@ -245,37 +578,252 @@ class StatusPill(QWidget): self._pulse.stop() -class Card(QFrame): - """Rounded surface with an optional title row, matching ``BecUi.Card``.""" +class Badge(_ThemeFollower, QWidget): + """Count bubble, matching ``BecUi.Badge``. Hidden at zero unless ``show_zero``.""" - def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12): + def __init__( + self, + parent: QWidget | None = None, + count: int = 0, + tone: str = "danger", + solid: bool | None = None, + show_zero: bool = False, + maximum: int = 99, + ): + super().__init__(parent) + self._count = 0 + self._tone = tone + self._solid = solid + self._show_zero = show_zero + self._maximum = maximum + self.setFont(_px_font(self, 10, QFont.Weight.Bold)) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.set_count(count) + self._follow_theme() + + @property + def label(self) -> str: + """Displayed text, e.g. ``99+``.""" + return f"{self._maximum}+" if self._count > self._maximum else str(self._count) + + def set_count(self, count: int) -> None: + """Set the number shown.""" + self._count = int(count) + self.setVisible(self._count > 0 or self._show_zero) + self.setAccessibleName(self.label) + self.updateGeometry() + self.update() + + def set_tone(self, tone: str) -> None: + """Set the tone name.""" + self._tone = tone + self.update() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new colours.""" + self.update() + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(max(16, self.fontMetrics().horizontalAdvance(self.label) + 9), 16) + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens.current() + solid = self._solid if self._solid is not None else self._tone not in ("neutral", "primary") + base = tokens.primary if self._tone == "primary" else tokens.tone(self._tone) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + fill = QColor(base) + if not solid: + fill.setAlphaF(0.22 if tokens.dark else 0.14) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(fill) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + if solid: + luma = 0.299 * base.redF() + 0.587 * base.greenF() + 0.114 * base.blueF() + text = QColor("#1a1a1a") if luma > 0.6 else QColor("white") + else: + text = tokens.tone_text(self._tone) + painter.setPen(text) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self.label) + painter.end() + + +class LinearProgress(_ThemeFollower, QWidget): + """Thin progress bar, matching ``BecUi.LinearProgress``. + + ``set_value`` takes 0..1 and animates; ``set_indeterminate`` shows a sliding segment. + """ + + def __init__(self, parent: QWidget | None = None, tone: str = "primary", thickness: int = 6): + super().__init__(parent) + self._tone = tone + self._value = 0.0 + self._shown = 0.0 + self._indeterminate = False + self._phase = 0.0 + self.setFixedHeight(thickness) + self.setMinimumWidth(40) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self._animation = QVariantAnimation(self) + self._animation.setDuration(220) + self._animation.setEasingCurve(QEasingCurve.Type.OutCubic) + self._animation.valueChanged.connect(self._on_animated) + self._runner = QVariantAnimation(self) + self._runner.setStartValue(0.0) + self._runner.setEndValue(1.0) + self._runner.setDuration(1300) + self._runner.setLoopCount(-1) + self._runner.setEasingCurve(QEasingCurve.Type.InOutQuad) + self._runner.valueChanged.connect(self._on_phase) + self._follow_theme() + + @property + def value(self) -> float: + """Target value in 0..1.""" + return self._value + + def set_value(self, value: float, animate: bool = True) -> None: + """Set the progress in 0..1.""" + self._value = max(0.0, min(1.0, float(value))) + self._animation.stop() + if animate and self.isVisible(): + self._animation.setStartValue(self._shown) + self._animation.setEndValue(self._value) + self._animation.start() + else: + self._shown = self._value + self.update() + + def set_indeterminate(self, indeterminate: bool) -> None: + """Switch between a determinate bar and a sliding busy segment.""" + self._indeterminate = indeterminate + if indeterminate: + self._runner.start() + else: + self._runner.stop() + self.update() + + def set_tone(self, tone: str) -> None: + """Fill tone: ``primary``, ``success``, ``warning``, ``danger`` ...""" + self._tone = tone + self.update() + + def _on_animated(self, value) -> None: + self._shown = float(value) + self.update() + + def _on_phase(self, value) -> None: + self._phase = float(value) + self.update() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new colours.""" + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + tokens = ThemeTokens.current() + color = tokens.primary if self._tone == "primary" else tokens.tone(self._tone) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + radius = rect.height() / 2 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.track) + painter.drawRoundedRect(rect, radius, radius) + painter.setBrush(color) + if self._indeterminate: + width = rect.width() * 0.3 + left = -width + (rect.width() + width) * self._phase + painter.setClipRect(rect) + painter.drawRoundedRect(QRectF(left, 0, width, rect.height()), radius, radius) + elif self._shown > 0: + width = max(rect.height(), rect.width() * self._shown) + painter.drawRoundedRect(QRectF(0, 0, width, rect.height()), radius, radius) + painter.end() + + def cleanup(self) -> None: + """Stop the animations.""" + self._animation.stop() + self._runner.stop() + + +# -------------------------------------------------------------------------------------------- +# Surfaces and feedback +# -------------------------------------------------------------------------------------------- + + +class Card(_ThemeFollower, QFrame): + """Rounded surface with an optional title row, matching ``BecUi.Card``. + + Put content into :attr:`body`; add buttons to the title row with :meth:`add_header_widget`. + + Args: + title(str): Title text. + parent(QWidget | None): Parent widget. + padding(int): Inner padding. + title_style(str): ``"title"`` or ``"caption"`` (small uppercase, for form sections). + collapsible(bool): Clicking the title row hides or shows the body. + icon_name(str): Optional icon before the title. + surface(str): ``"card"`` or ``"sunken"`` (a nested section inside a card). + """ + + expanded_changed = Signal(bool) + + def __init__( + self, + title: str = "", + parent: QWidget | None = None, + padding: int = 12, + title_style: str = "title", + collapsible: bool = False, + icon_name: str = "", + surface: str = "card", + ): super().__init__(parent) self.setObjectName("uxCard") + self._title_style = title_style + self._collapsible = collapsible + self._expanded = True + self._icon_name = icon_name + self._surface = surface outer = QVBoxLayout(self) outer.setContentsMargins(padding, padding, padding, padding) outer.setSpacing(10) self.header = QHBoxLayout() - self.header.setSpacing(8) - self.title_label = QLabel(title, self) + self.header.setSpacing(6) + self.chevron = QLabel(self) + self.chevron.setVisible(collapsible) + self.icon_label = QLabel(self) + self.icon_label.setVisible(bool(icon_name)) + self.title_label = QLabel("", self) self.subtitle_label = QLabel("", self) self.subtitle_label.setVisible(False) + self.header.addWidget(self.chevron) + self.header.addWidget(self.icon_label) self.header.addWidget(self.title_label) self.header.addWidget(self.subtitle_label, 1) + self.header.addStretch(1) self.header_extras = QHBoxLayout() self.header_extras.setSpacing(4) self.header.addLayout(self.header_extras) outer.addLayout(self.header) - self.body = QVBoxLayout() + self.body_widget = QWidget(self) + self.body_widget.setObjectName("uxCardBody") + self.body = QVBoxLayout(self.body_widget) + self.body.setContentsMargins(0, 0, 0, 0) self.body.setSpacing(8) - outer.addLayout(self.body) - self._has_title = bool(title) - self._update_header_visibility() - self.refresh_theme(ThemeTokens()) + outer.addWidget(self.body_widget, 1) + self._has_extras = False + self.set_title(title) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() def set_title(self, title: str, subtitle: str = "") -> None: """Set the title and an optional muted subtitle.""" - self._has_title = bool(title) - self.title_label.setText(title) + self._title = title + shown = title.upper() if self._title_style == "caption" else title + self.title_label.setText(shown) self.subtitle_label.setText(subtitle) self.subtitle_label.setVisible(bool(subtitle)) self._update_header_visibility() @@ -283,48 +831,318 @@ class Card(QFrame): def add_header_widget(self, widget: QWidget) -> None: """Add a widget to the right end of the title row.""" self.header_extras.addWidget(widget) - self._has_title = True + self._has_extras = True self._update_header_visibility() + @property + def expanded(self) -> bool: + """Whether the body is shown.""" + return self._expanded + + def set_expanded(self, expanded: bool) -> None: + """Show or hide the body of a collapsible card.""" + if expanded == self._expanded: + return + self._expanded = expanded + self.body_widget.setVisible(expanded) + self._update_chevron(ThemeTokens.current()) + self.expanded_changed.emit(expanded) + + def mousePressEvent(self, event): # pylint: disable=invalid-name + if self._collapsible and event.position().y() <= self.body_widget.geometry().top(): + self.set_expanded(not self._expanded) + event.accept() + return + super().mousePressEvent(event) + def _update_header_visibility(self) -> None: - self.title_label.setVisible(bool(self.title_label.text())) + has_title = bool(self.title_label.text()) + self.title_label.setVisible(has_title) + visible = has_title or self._has_extras + for index in range(self.header.count()): + item = self.header.itemAt(index).widget() + if item is not None and item not in (self.subtitle_label, self.chevron): + if item is self.icon_label: + item.setVisible(visible and bool(self._icon_name)) + elif item is self.title_label: + item.setVisible(has_title) + self.chevron.setVisible(self._collapsible and visible) + + def _update_chevron(self, tokens: ThemeTokens) -> None: + if self._collapsible: + name = "expand_more" if self._expanded else "chevron_right" + self.chevron.setPixmap( + material_icon( + name, size=(36, 36), color=tokens.fg_muted, convert_to_pixmap=True + ).scaled( + 18, + 18, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) def refresh_theme(self, tokens: ThemeTokens) -> None: """Apply the theme tokens.""" + background = tokens.sunken if self._surface == "sunken" else tokens.card self.setStyleSheet( - f"QFrame#uxCard {{ background: {tokens.card.name()};" - f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" - ) - self.title_label.setStyleSheet( - f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + f"QFrame#uxCard {{ background: {background.name()};" + f" border: 1px solid {tokens.border.name()};" + f" border-radius: {tokens.metrics['radiusLarge']}px; }}" + f"QWidget#uxCardBody {{ background: transparent; }}" ) + if self._title_style == "caption": + self.title_label.setStyleSheet( + f"color: {tokens.fg_muted.name()}; font-size: {tokens.metrics['fontCaption']}px;" + f" font-weight: 700; letter-spacing: 0.8px; background: none;" + ) + else: + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: {tokens.metrics['fontBody']}px;" + f" font-weight: 600; background: none;" + ) self.subtitle_label.setStyleSheet( - f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;" + f"color: {tokens.fg_subtle.name()}; font-size: {tokens.metrics['fontSmall']}px;" + f" background: none;" ) + self.chevron.setStyleSheet("background: none;") + self.icon_label.setStyleSheet("background: none;") + if self._icon_name: + self.icon_label.setPixmap( + material_icon( + self._icon_name, size=(32, 32), color=tokens.fg_muted, convert_to_pixmap=True + ).scaled( + 16, + 16, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + self._update_chevron(tokens) -class PortedPropertiesMixin: - """Make a port export and load the ``SafeProperty`` settings of the class it ports. +class Divider(_ThemeFollower, QFrame): + """Hairline separator, matching ``BecUi.Divider``.""" - ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a - subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. - Set ``PORTED_FROM`` to the original class; its properties and those of every class in - between are collected. + def __init__(self, parent: QWidget | None = None, vertical: bool = False): + super().__init__(parent) + if vertical: + self.setFixedWidth(1) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding) + else: + self.setFixedHeight(1) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + self.setStyleSheet(f"background: {tokens.separator.name()}; border: none;") + + +class Banner(_ThemeFollower, QFrame): + """Inline message with a tone icon, title, text, action and close button. + + Matches ``BecUi.Banner``. + + Signals: + action_triggered: The action button was clicked. + closed: The close button was clicked; the banner hides itself. """ - PORTED_FROM: type | None = None + action_triggered = Signal() + closed = Signal() - def _get_bec_meta_objects(self) -> dict: - objects = {} - for cls in type(self).__mro__: - for name, attr in vars(cls).items(): - if name in objects or not isinstance(attr, Property): - continue - if getattr(attr.fget, "__is_safe_getter__", False): - objects[name] = attr - if cls is self.PORTED_FROM: - break - return objects + _ICONS = {"success": "check_circle", "warning": "warning", "danger": "error", "info": "info"} + + def __init__( + self, + tone: str = "info", + title: str = "", + text: str = "", + parent: QWidget | None = None, + action_text: str = "", + closable: bool = False, + icon_name: str = "", + ): + super().__init__(parent) + self.setObjectName("uxBanner") + self._tone = ThemeTokens.tone_name(tone) + self._icon_name = icon_name + layout = QHBoxLayout(self) + layout.setContentsMargins(16, 10, 8, 10) + layout.setSpacing(10) + self.icon_label = QLabel(self) + self.icon_label.setFixedSize(18, 18) + layout.addWidget(self.icon_label, 0, Qt.AlignmentFlag.AlignTop) + texts = QVBoxLayout() + texts.setSpacing(2) + self.title_label = QLabel(title, self) + self.title_label.setWordWrap(True) + self.text_label = QLabel(text, self) + self.text_label.setWordWrap(True) + self.text_label.setTextFormat(Qt.TextFormat.PlainText) + texts.addWidget(self.title_label) + texts.addWidget(self.text_label) + layout.addLayout(texts, 1) + self.action_button = TextButton(action_text, "neutral", parent=self, compact=True) + self.action_button.setVisible(bool(action_text)) + self.action_button.clicked.connect(self.action_triggered) + layout.addWidget(self.action_button, 0, Qt.AlignmentFlag.AlignVCenter) + self.close_button = IconButton("close", "Dismiss", self, size=16, compact=True) + self.close_button.setVisible(closable) + self.close_button.clicked.connect(self._on_close) + layout.addWidget(self.close_button, 0, Qt.AlignmentFlag.AlignTop) + self.set_message(title, text) + self.setAccessibleName(f"{title} {text}".strip()) + self._follow_theme() + + def set_message(self, title: str, text: str, tone: str | None = None) -> None: + """Update the content and optionally the tone.""" + if tone is not None: + self._tone = ThemeTokens.tone_name(tone) + self.title_label.setText(title) + self.title_label.setVisible(bool(title)) + self.text_label.setText(text) + self.text_label.setVisible(bool(text)) + self.refresh_theme(ThemeTokens.current()) + + def _on_close(self) -> None: + self.hide() + self.closed.emit() + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + base = tokens.tone(self._tone) + tint = tokens.tone_tint(self._tone) + text = tokens.tone_text(self._tone) + radius = tokens.metrics["radiusSmall"] + 2 + self.setStyleSheet( + f"QFrame#uxBanner {{ background: {tint.name()}; border: 1px solid {rgba(base, 0.45)};" + f" border-radius: {radius}px; }}" + ) + has_title = bool(self.title_label.text()) + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: {tokens.metrics['fontBody']}px;" + f" font-weight: 600; background: none; border: none;" + ) + size = tokens.metrics["fontSmall" if has_title else "fontBody"] + color = tokens.fg_muted if has_title else tokens.fg + self.text_label.setStyleSheet( + f"color: {color.name()}; font-size: {size}px; background: none; border: none;" + ) + self.icon_label.setStyleSheet("background: none; border: none;") + icon = self._icon_name or self._ICONS.get(self._tone, "info") + self.icon_label.setPixmap( + material_icon( + icon, size=(36, 36), color=text, filled=True, convert_to_pixmap=True + ).scaled( + 18, + 18, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + self.update() + + def paintEvent(self, event): # pylint: disable=invalid-name + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(ThemeTokens.current().tone(self._tone)) + painter.drawRoundedRect(QRectF(6, 6, 3, self.height() - 12), 1.5, 1.5) + painter.end() + + +class EmptyState(_ThemeFollower, QWidget): + """Placeholder for an empty list or view, matching ``BecUi.EmptyState``.""" + + action_triggered = Signal() + + def __init__( + self, + icon_name: str = "inbox", + title: str = "", + text: str = "", + parent: QWidget | None = None, + action_text: str = "", + action_icon: str = "", + compact: bool = False, + ): + super().__init__(parent) + self._icon_name = icon_name + self._compact = compact + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4 if compact else 8) + self.icon_label = QLabel(self) + side = 36 if compact else 52 + self.icon_label.setFixedSize(side, side) + self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.icon_label, 0, Qt.AlignmentFlag.AlignHCenter) + self.title_label = QLabel(title, self) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.title_label.setVisible(bool(title)) + layout.addSpacing(2 if compact else 4) + layout.addWidget(self.title_label) + self.text_label = QLabel(text, self) + self.text_label.setWordWrap(True) + self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.text_label.setVisible(bool(text)) + layout.addWidget(self.text_label) + self.action_button = TextButton(action_text, "neutral", action_icon, self, compact=compact) + self.action_button.setVisible(bool(action_text)) + self.action_button.clicked.connect(self.action_triggered) + layout.addSpacing(6) + layout.addWidget(self.action_button, 0, Qt.AlignmentFlag.AlignHCenter) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() + + def set_content(self, title: str, text: str = "", icon_name: str | None = None) -> None: + """Change title, text and optionally the icon.""" + if icon_name is not None: + self._icon_name = icon_name + self.title_label.setText(title) + self.title_label.setVisible(bool(title)) + self.text_label.setText(text) + self.text_label.setVisible(bool(text)) + self.refresh_theme(ThemeTokens.current()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + side = self.icon_label.width() + icon = 20 if self._compact else 28 + self.icon_label.setStyleSheet( + f"background: {tokens.hover.name()}; border-radius: {side // 2}px;" + ) + self.icon_label.setPixmap( + material_icon( + self._icon_name, + size=(icon * 2, icon * 2), + color=tokens.fg_subtle, + convert_to_pixmap=True, + ).scaled( + icon, + icon, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + title_px = tokens.metrics["fontBody" if self._compact else "fontTitle"] + text_px = tokens.metrics["fontSmall" if self._compact else "fontBody"] + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: {title_px}px; font-weight: 600;" + f" background: none;" + ) + self.text_label.setStyleSheet( + f"color: {tokens.fg_muted.name()}; font-size: {text_px}px; background: none;" + ) + + +# -------------------------------------------------------------------------------------------- +# Inputs +# -------------------------------------------------------------------------------------------- class ToggleSwitch(QPushButton): @@ -366,8 +1184,12 @@ class ToggleSwitch(QPushButton): knob = Property(float, _get_knob, _set_knob) + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new colours.""" + self.update() + def paintEvent(self, _event): # pylint: disable=invalid-name - tokens = ThemeTokens() + tokens = ThemeTokens.current() painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) track = QRectF(0, (self.height() - 22) / 2, 38, 22) @@ -390,11 +1212,15 @@ class ToggleSwitch(QPushButton): class SuffixLineEdit(QLineEdit): - """Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``.""" + """Line edit that paints a unit suffix inside its right edge, like ``BecUi.InputField``. + + Style it with :func:`field_qss`; ``set_icon_name`` adds a leading icon. + """ def __init__(self, parent: QWidget | None = None): super().__init__(parent) self._suffix = "" + self._icon_action = None def set_suffix(self, suffix: str) -> None: """Set the unit shown at the right edge.""" @@ -403,12 +1229,26 @@ class SuffixLineEdit(QLineEdit): self.setTextMargins(0, 0, width, 0) self.update() + def set_icon_name(self, icon_name: str) -> None: + """Show a Material icon at the left edge.""" + if self._icon_action is not None: + self.removeAction(self._icon_action) + self._icon_action = None + if icon_name: + icon = material_icon( + icon_name, + size=(32, 32), + color=ThemeTokens.current().fg_subtle, + convert_to_pixmap=False, + ) + self._icon_action = self.addAction(icon, QLineEdit.ActionPosition.LeadingPosition) + def paintEvent(self, event): # pylint: disable=invalid-name super().paintEvent(event) if not self._suffix: return painter = QPainter(self) - painter.setPen(ThemeTokens().fg_subtle) + painter.setPen(ThemeTokens.current().fg_subtle) font = self.font() font.setPixelSize(12) painter.setFont(font) @@ -418,3 +1258,280 @@ class SuffixLineEdit(QLineEdit): self._suffix, ) painter.end() + + +class SearchField(_ThemeFollower, QLineEdit): + """Search input with a magnifier, a clear button and a shortcut hint. + + Matches ``BecUi.SearchField``. Escape clears the text and emits :attr:`cleared`. + """ + + cleared = Signal() + + def __init__( + self, parent: QWidget | None = None, placeholder: str = "Search", shortcut_hint: str = "" + ): + super().__init__(parent) + self._hint = shortcut_hint + self.setPlaceholderText(placeholder) + self.setClearButtonEnabled(True) + self.setAccessibleName(placeholder) + self._search_action = None + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() + + def keyPressEvent(self, event): # pylint: disable=invalid-name + if event.matches(QKeySequence.StandardKey.Cancel) and self.text(): + self.clear() + self.cleared.emit() + event.accept() + return + super().keyPressEvent(event) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + if self._search_action is not None: + self.removeAction(self._search_action) + icon = material_icon( + "search", size=(32, 32), color=tokens.fg_subtle, convert_to_pixmap=False + ) + self._search_action = self.addAction(icon, QLineEdit.ActionPosition.LeadingPosition) + self.setStyleSheet(field_qss(tokens)) + + def paintEvent(self, event): # pylint: disable=invalid-name + super().paintEvent(event) + if not self._hint or self.text() or self.hasFocus(): + return + tokens = ThemeTokens.current() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + font = _px_font(self, tokens.metrics["fontCaption"]) + painter.setFont(font) + width = painter.fontMetrics().horizontalAdvance(self._hint) + 10 + rect = QRectF(self.width() - width - 8, (self.height() - 18) / 2, width, 18) + painter.setPen(QPen(tokens.border, 1)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRoundedRect(rect.adjusted(0.5, 0.5, -0.5, -0.5), 4, 4) + painter.setPen(tokens.fg_subtle) + painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, self._hint) + painter.end() + + +class FormField(_ThemeFollower, QWidget): + """Label above a control with a required mark, unit, helper and error line. + + Matches ``BecUi.FormField``. :meth:`set_error` replaces the helper with a red message and + marks the control invalid (see :func:`set_invalid`). + """ + + def __init__( + self, + label: str, + control: QWidget, + parent: QWidget | None = None, + required: bool = False, + helper: str = "", + unit: str = "", + ): + super().__init__(parent) + self.control = control + self._helper = helper + self._error = "" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(4) + text = label.replace("{", "{{").replace("}", "}}") + if required: + text += " *" + if unit: + text += f" ({unit})" + self._label_template = text + self.label = QLabel(self) + self.label.setTextFormat(Qt.TextFormat.RichText) + self.label.setVisible(bool(label)) + layout.addWidget(self.label) + layout.addWidget(control) + self.message_row = QWidget(self) + message = QHBoxLayout(self.message_row) + message.setContentsMargins(0, 0, 0, 0) + message.setSpacing(4) + self.error_icon = QLabel(self.message_row) + self.error_icon.setFixedSize(13, 13) + self.message = QLabel(self.message_row) + self.message.setWordWrap(True) + message.addWidget(self.error_icon, 0, Qt.AlignmentFlag.AlignTop) + message.addWidget(self.message, 1) + layout.addWidget(self.message_row) + self.refresh_theme(ThemeTokens.current()) + self._follow_theme() + + def set_error(self, error: str) -> None: + """Show an error under the control, or clear it with an empty string.""" + self._error = error or "" + set_invalid(self.control, bool(self._error)) + self.refresh_theme(ThemeTokens.current()) + + def set_helper(self, helper: str) -> None: + """Set the helper line shown when there is no error.""" + self._helper = helper + self.refresh_theme(ThemeTokens.current()) + + @property + def error(self) -> str: + """Current error message.""" + return self._error + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + small = tokens.metrics["fontSmall"] + self.label.setText( + self._label_template.format( + danger=tokens.danger_text.name(), subtle=tokens.fg_subtle.name() + ) + ) + self.label.setStyleSheet( + f"color: {tokens.fg_muted.name()}; font-size: {small}px; font-weight: 500;" + f" background: none;" + ) + shown = self._error or self._helper + self.message_row.setVisible(bool(shown)) + self.message.setText(shown) + color = tokens.danger_text if self._error else tokens.fg_subtle + self.message.setStyleSheet( + f"color: {color.name()}; font-size: {small}px; background: none;" + ) + self.error_icon.setVisible(bool(self._error)) + if self._error: + self.error_icon.setPixmap( + material_icon( + "error", + size=(26, 26), + color=tokens.danger_text, + filled=True, + convert_to_pixmap=True, + ).scaled( + 13, + 13, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + + +class SegmentedControl(_ThemeFollower, QFrame): + """Row of mutually exclusive options, matching ``BecUi.SegmentedControl``. + + Items are strings or ``{"text": ..., "icon_name": ..., "count": ...}`` dictionaries. + :attr:`activated` fires on user clicks; :meth:`set_current_index` changes the state silently. + """ + + activated = Signal(int) + + def __init__( + self, items: Sequence[str | dict] = (), parent: QWidget | None = None, compact: bool = False + ): + super().__init__(parent) + self.setObjectName("uxSegmented") + self._compact = compact + self._current = 0 + self._buttons: list[QPushButton] = [] + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(3, 3, 3, 3) + self._layout.setSpacing(0) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.set_items(items) + self._follow_theme() + + def set_items(self, items: Sequence[str | dict]) -> None: + """Replace the options.""" + for button in self._buttons: + button.deleteLater() + self._buttons = [] + self._items = [item if isinstance(item, dict) else {"text": item} for item in items] + for index, item in enumerate(self._items): + button = QPushButton(self) + button.setCheckable(True) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setAccessibleName(item.get("text", "")) + button.clicked.connect(lambda _=False, i=index: self._on_clicked(i)) + self._layout.addWidget(button) + self._buttons.append(button) + self._current = min(self._current, max(0, len(self._buttons) - 1)) + self.refresh_theme(ThemeTokens.current()) + + def set_count(self, index: int, count: int) -> None: + """Update the count shown next to an option.""" + self._items[index]["count"] = count + self.refresh_theme(ThemeTokens.current()) + + @property + def current_index(self) -> int: + """Index of the selected option.""" + return self._current + + def set_current_index(self, index: int) -> None: + """Select an option without emitting :attr:`activated`.""" + self._current = index + for i, button in enumerate(self._buttons): + button.setChecked(i == index) + + def _on_clicked(self, index: int) -> None: + self.set_current_index(index) + self.activated.emit(index) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + height = tokens.metrics["controlHeightCompact" if self._compact else "controlHeight"] + font_px = tokens.metrics["fontSmall" if self._compact else "fontBody"] + radius = tokens.metrics["radiusSmall"] + self.setFixedHeight(height) + self.setStyleSheet( + f"QFrame#uxSegmented {{ background: {tokens.sunken.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: {radius + 1}px; }}" + f"QFrame#uxSegmented QPushButton {{ background: transparent; border: 1px solid" + f" transparent; border-radius: {radius - 1}px; color: {tokens.fg_muted.name()};" + f" font-size: {font_px}px; font-weight: 500;" + f" padding: 0 {8 if self._compact else 11}px; min-height: {height - 8}px; }}" + f"QFrame#uxSegmented QPushButton:hover {{ color: {tokens.fg.name()}; }}" + f"QFrame#uxSegmented QPushButton:checked {{ background: {tokens.card.name()};" + f" border-color: {tokens.border.name()}; color: {tokens.fg.name()};" + f" font-weight: 600; }}" + ) + for index, (button, item) in enumerate(zip(self._buttons, self._items)): + text = item.get("text", "") + count = item.get("count") + button.setText(f"{text} {count}" if count is not None else text) + if item.get("icon_name"): + button.setIcon( + material_icon( + item["icon_name"], + size=(30, 30), + color=tokens.fg_muted, + convert_to_pixmap=False, + ) + ) + button.setChecked(index == self._current) + + +class PortedPropertiesMixin: + """Make a port export and load the ``SafeProperty`` settings of the class it ports. + + ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a + subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. + Set ``PORTED_FROM`` to the original class; its properties and those of every class in + between are collected. + """ + + PORTED_FROM: type | None = None + + def _get_bec_meta_objects(self) -> dict: + objects = {} + for cls in type(self).__mro__: + for name, attr in vars(cls).items(): + if name in objects or not isinstance(attr, Property): + continue + if getattr(attr.fget, "__is_safe_getter__", False): + objects[name] = attr + if cls is self.PORTED_FROM: + break + return objects diff --git a/tests/unit_tests/test_ui_kit.py b/tests/unit_tests/test_ui_kit.py new file mode 100644 index 00000000..999f99b0 --- /dev/null +++ b/tests/unit_tests/test_ui_kit.py @@ -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()