diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 56b0f853..f8291bd5 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -244,7 +244,7 @@ class BECDockArea(RPCBase): @rpc_call def delete_all(self): """ - Delete all docks and their associated widgets. + Delete all docks and their associated widgets, including ones waiting for Undo. """ @rpc_call @@ -1296,7 +1296,7 @@ class DockAreaView(RPCBase): @rpc_call def delete_all(self): """ - Delete all docks and their associated widgets. + Delete all docks and their associated widgets, including ones waiting for Undo. """ @rpc_call diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -0,0 +1,26 @@ +"""Shared plumbing for widgets that render their content with Qt Quick (QML). + +The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering +layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and +driven by a small Python backend object. +""" + +from bec_widgets.utils.quick.host import ( + QML_IMPORT_PATH, + MaterialIconProvider, + QmlTheme, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.utils.quick.list_model import DictListModel + +__all__ = [ + "QML_IMPORT_PATH", + "DictListModel", + "MaterialIconProvider", + "QmlTheme", + "create_quick_widget", + "quick_engine", + "release_quick_widget", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -0,0 +1,233 @@ +"""Hosting helpers for QML views embedded into BEC widgets. + +All QML views share one :class:`QQmlEngine` per application. The engine carries the +application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from +``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path +of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial +properties of the root item, typically a single ``backend`` QObject. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +QML_IMPORT_PATH = Path(__file__).parent / "qml" + +_DARK_FALLBACK = { + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "ACCENT_DEFAULT": "#8ab4f7", + "ACCENT_HIGHLIGHT": "#B53565", + "ACCENT_WARNING": "#EAC435", + "ACCENT_EMERGENCY": "#CC181E", + "ACCENT_SUCCESS": "#2CA58D", +} + + +def _blend(base: QColor, top: QColor, amount: float) -> QColor: + """Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top).""" + return QColor.fromRgbF( + base.redF() + (top.redF() - base.redF()) * amount, + base.greenF() + (top.greenF() - base.greenF()) * amount, + base.blueF() + (top.blueF() - base.blueF()) * amount, + 1.0, + ) + + +class ThemeTokens: + """Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports.""" + + def __init__(self): + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + name = getattr(theme, "theme", "dark") if theme is not None else "dark" + + def color(key: str) -> QColor: + fallback = _DARK_FALLBACK.get(key, "#808080") + if theme is None: + return QColor(fallback) + return QColor(theme.color(key, fallback)) + + self.name: str = str(name) + self.bg = color("BG") + self.card = color("CARD_BG") + self.field = color("FIELD_BG") + self.border = color("BORDER") + self.fg = color("FG") + self.primary = color("PRIMARY") + self.on_primary = color("ON_PRIMARY") + self.accent = color("ACCENT_DEFAULT") + self.highlight = color("ACCENT_HIGHLIGHT") + self.warning = color("ACCENT_WARNING") + self.danger = color("ACCENT_EMERGENCY") + self.success = color("ACCENT_SUCCESS") + self.dark = self.bg.lightnessF() < 0.5 + self.fg_muted = _blend(self.card, self.fg, 0.62) + self.fg_subtle = _blend(self.card, self.fg, 0.4) + self.hover = _blend(self.card, self.fg, 0.06) + self.pressed = _blend(self.card, self.fg, 0.12) + self.track = _blend(self.card, self.fg, 0.1) + + def soft(self, color: QColor, amount: float = 0.16) -> QColor: + """Tint ``color`` onto the card background, e.g. for chips and banners.""" + return _blend(self.card, color, amount) + + +class QmlTheme(QObject): + """Expose the BEC theme colours to QML as ``theme.`` and follow theme changes.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._tokens = ThemeTokens() + self._connected_theme = None + self._connect_theme() + + def _connect_theme(self) -> None: + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + if theme is None or theme is self._connected_theme: + return + theme.theme_changed.connect(self.refresh) + self._connected_theme = theme + + def refresh(self, *_args) -> None: + """Re-read the tokens from the application theme.""" + self._connect_theme() + self._tokens = ThemeTokens() + self.changed.emit() + + # pylint: disable=missing-function-docstring + def _get(self, name: str) -> QColor: + return getattr(self._tokens, name) + + name = Property(str, lambda self: self._tokens.name, notify=changed) + dark = Property(bool, lambda self: self._tokens.dark, notify=changed) + bg = Property(QColor, lambda self: self._get("bg"), notify=changed) + card = Property(QColor, lambda self: self._get("card"), notify=changed) + field = Property(QColor, lambda self: self._get("field"), notify=changed) + border = Property(QColor, lambda self: self._get("border"), notify=changed) + fg = Property(QColor, lambda self: self._get("fg"), notify=changed) + fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed) + fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed) + hover = Property(QColor, lambda self: self._get("hover"), notify=changed) + pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed) + track = Property(QColor, lambda self: self._get("track"), notify=changed) + primary = Property(QColor, lambda self: self._get("primary"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed) + accent = Property(QColor, lambda self: self._get("accent"), notify=changed) + highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed) + warning = Property(QColor, lambda self: self._get("warning"), notify=changed) + danger = Property(QColor, lambda self: self._get("danger"), notify=changed) + success = Property(QColor, lambda self: self._get("success"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """Serve Material icons to QML as ``image://material/?color=%23rrggbb&filled=1``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + options = parse_qs(query) + color = options.get("color", [None])[0] + filled = options.get("filled", ["0"])[0] in ("1", "true") + target = requested_size if requested_size.isValid() else QSize(24, 24) + if target.width() <= 0 or target.height() <= 0: + target = QSize(24, 24) + pixmap = material_icon(name, size=target, color=color, filled=filled) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_ENGINE: QQmlEngine | None = None +_THEME: QmlTheme | None = None + + +def quick_engine() -> QQmlEngine: + """Return the application-wide QML engine used by all BEC QML views.""" + global _ENGINE, _THEME # pylint: disable=global-statement + if _ENGINE is not None: + if _THEME._connected_theme is None: # pylint: disable=protected-access + # the theme was applied after the engine was created + _THEME.refresh() + return _ENGINE + app = QApplication.instance() + _ENGINE = QQmlEngine(app) + _ENGINE.addImportPath(str(QML_IMPORT_PATH)) + _ENGINE.addImageProvider("material", MaterialIconProvider()) + _THEME = QmlTheme(_ENGINE) + _ENGINE.rootContext().setContextProperty("theme", _THEME) + _ENGINE.warnings.connect( + lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings] + ) + if app is not None: + app.aboutToQuit.connect(_drop_engine) + return _ENGINE + + +def _drop_engine() -> None: + global _ENGINE, _THEME # pylint: disable=global-statement + _ENGINE = None + _THEME = None + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, properties: dict | None = None +) -> QQuickWidget: + """Create a QQuickWidget showing ``qml_file`` on the shared engine. + + Args: + parent(QWidget): Parent widget, usually the BEC widget shell. + qml_file(str | Path): Path of the root QML file. + properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``. + + Returns: + QQuickWidget: The view, already loaded. Load errors are logged. + """ + view = QQuickWidget(quick_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) + view.setClearColor(parent.palette().window().color()) + if properties: + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + for error in view.errors(): + logger.error(f"QML error in {qml_file}: {error.toString()}") + return view + + +def release_quick_widget(view: QQuickWidget | None) -> None: + """Unload the QML scene of ``view`` before its backend objects are destroyed. + + Without this, bindings of a still-loaded scene are re-evaluated against already deleted + backends during teardown and log ``TypeError`` warnings. + """ + if view is None: + return + try: + view.setSource(QUrl()) + except RuntimeError: + # the C++ object is already gone + return diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -0,0 +1,83 @@ +"""A small list model that feeds lists of dictionaries to QML views.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal + + +class DictListModel(QAbstractListModel): + """List model whose rows are plain dictionaries with a fixed set of keys. + + Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is + unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes + instead of the delegates being rebuilt. + + Args: + roles(Sequence[str]): Keys of the row dictionaries exposed as roles. + parent: Parent QObject. + """ + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent=None): + super().__init__(parent) + self._keys = list(roles) + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)} + self._items: list[dict[str, Any]] = [] + + # pylint: disable=invalid-name + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._items): + return None + key = self._roles.get(role) + if key is None: + return None + return self._items[index.row()].get(key) + + @property + def items(self) -> list[dict[str, Any]]: + """Copy of the current rows.""" + return [dict(item) for item in self._items] + + def set_items(self, items: Sequence[dict[str, Any]]) -> None: + """Replace the rows, reusing existing rows where possible. + + Args: + items(Sequence[dict]): New rows. + """ + items = [dict(item) for item in items] + old_count = len(self._items) + new_count = len(items) + common = min(old_count, new_count) + for row in range(common): + changed = [ + role + for role, key in self._roles.items() + if self._items[row].get(key) != items[row].get(key) + ] + self._items[row] = items[row] + if changed: + index = self.index(row, 0) + self.dataChanged.emit(index, index, changed) + if new_count > old_count: + self.beginInsertRows(QModelIndex(), old_count, new_count - 1) + self._items.extend(items[old_count:]) + self.endInsertRows() + elif new_count < old_count: + self.beginRemoveRows(QModelIndex(), new_count, old_count - 1) + del self._items[new_count:] + self.endRemoveRows() + if new_count != old_count: + self.count_changed.emit() + + count = Property(int, lambda self: len(self._items), notify=count_changed) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..eadf5d56 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Layouts + +// Rounded surface with an optional title row. Children go into the content column. +Rectangle { + id: root + property string title: "" + property string subtitle: "" + default property alias content: column.data + property alias headerExtras: extras.data + property int padding: 12 + property alias spacing: column.spacing + + color: theme.card + radius: 10 + border.color: theme.border + border.width: 1 + implicitHeight: outer.implicitHeight + 2 * padding + implicitWidth: outer.implicitWidth + 2 * padding + + ColumnLayout { + id: outer + anchors.fill: parent + anchors.margins: root.padding + spacing: 10 + RowLayout { + visible: root.title !== "" || extras.children.length > 0 + Layout.fillWidth: true + spacing: 8 + Text { + text: root.title + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + text: root.subtitle + visible: text !== "" + color: theme.fgSubtle + font.pixelSize: 12 + Layout.fillWidth: true + elide: Text.ElideRight + } + Item { Layout.fillWidth: root.subtitle === "" } + Row { id: extras; spacing: 4 } + } + ColumnLayout { + id: column + Layout.fillWidth: true + spacing: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Background used by all input controls: field colour, border, focus and error states. +Rectangle { + property bool focused: false + property bool invalid: false + property bool hovered: false + radius: 6 + color: theme.field + border.width: focused || invalid ? 2 : 1 + border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -0,0 +1,25 @@ +import QtQuick + +// Material icon served by the "material" image provider, tinted with the given colour. +Item { + id: root + property string name: "" + property color color: theme.fg + property bool filled: false + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.centerIn: parent + width: root.size + height: root.size + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + smooth: true + fillMode: Image.PreserveAspectFit + source: root.name === "" ? "" : "image://material/" + root.name + "?color=" + + encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Flat, square icon button with hover and pressed feedback and a tooltip. +AbstractButton { + id: root + property string iconName: "" + property color iconColor: theme.fgMuted + property string tip: "" + property int iconSize: 18 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + focusPolicy: Qt.TabFocus + opacity: enabled ? 1.0 : 0.4 + Accessible.name: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + border.width: root.visualFocus ? 1 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.checked ? theme.primary : root.iconColor + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Single-line text input with optional unit suffix and error state. +TextField { + id: root + property bool invalid: false + property string suffix: "" + + implicitHeight: 32 + implicitWidth: 120 + color: theme.fg + placeholderTextColor: theme.fgSubtle + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 13 + leftPadding: 9 + rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9 + selectByMouse: true + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus + invalid: root.invalid + hovered: root.hovered + } + Text { + id: suffixLabel + visible: root.suffix !== "" + text: root.suffix + color: theme.fgSubtle + font.pixelSize: 12 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..d2149dce --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -0,0 +1,85 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Combo box styled like the input fields. With editable: true it filters while typing. +ComboBox { + id: root + property bool invalid: false + + implicitHeight: 32 + implicitWidth: 140 + font.pixelSize: 13 + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus || root.popup.visible + invalid: root.invalid + hovered: root.hovered + } + contentItem: TextField { + leftPadding: 9 + rightPadding: 4 + text: root.editable ? root.editText : root.displayText + readOnly: !root.editable + enabled: root.enabled + color: theme.fg + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font: root.font + verticalAlignment: Text.AlignVCenter + background: null + selectByMouse: true + // forward clicks to the combo when it is not editable + TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() } + } + indicator: Icon { + x: root.width - width - 6 + y: (root.height - height) / 2 + name: "expand_more" + size: 18 + color: theme.fgMuted + } + delegate: ItemDelegate { + id: option + required property int index + required property var modelData + width: ListView.view ? ListView.view.width : root.width + height: 30 + highlighted: root.highlightedIndex === index + contentItem: Text { + text: option.modelData === "" ? "None" : option.modelData + color: option.modelData === "" ? theme.fgSubtle : theme.fg + font.pixelSize: 13 + elide: Text.ElideRight + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: option.highlighted ? theme.hover : "transparent" + Rectangle { + width: 3; height: parent.height - 10; radius: 1.5 + anchors.verticalCenter: parent.verticalCenter + color: theme.primary + visible: root.currentIndex === option.index + } + } + } + popup: Popup { + y: root.height + 2 + width: root.width + implicitHeight: Math.min(contentItem.implicitHeight + 8, 280) + padding: 4 + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: root.popup.visible ? root.delegateModel : null + currentIndex: root.highlightedIndex + ScrollIndicator.vertical: ScrollIndicator {} + } + background: Rectangle { + color: theme.card + border.color: theme.border + radius: 8 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -0,0 +1,40 @@ +import QtQuick + +// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving". +Rectangle { + id: root + property string text: "" + property color tone: theme.fgMuted + property bool pulse: false + + implicitHeight: 22 + implicitWidth: label.implicitWidth + 26 + radius: height / 2 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.16) + + Rectangle { + id: dot + width: 7; height: 7; radius: 3.5 + color: root.tone + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + SequentialAnimation on opacity { + running: root.pulse + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad } + } + } + Text { + id: label + anchors.left: dot.right + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.tone + font.pixelSize: 12 + font.weight: Font.DemiBold + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Toggle switch for boolean parameters. +Switch { + id: root + implicitHeight: 32 + hoverEnabled: true + padding: 0 + indicator: Rectangle { + implicitWidth: 38 + implicitHeight: 22 + x: root.leftPadding + y: (root.height - height) / 2 + radius: 11 + color: root.checked ? theme.primary : theme.track + border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border) + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { + x: root.checked ? parent.width - width - 3 : 3 + y: 3 + width: 16; height: 16; radius: 8 + color: root.checked ? theme.onPrimary : theme.fgMuted + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + contentItem: Text { + leftPadding: root.indicator.width + 8 + text: root.text + color: theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost". +AbstractButton { + id: root + property string variant: "neutral" + property string iconName: "" + property string tip: "" + property bool busy: false + + readonly property color baseColor: variant === "primary" ? theme.primary + : variant === "danger" ? theme.danger + : variant === "success" ? theme.success + : "transparent" + readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success" + readonly property color labelColor: solid ? "white" : theme.fg + + implicitHeight: 32 + implicitWidth: Math.max(64, row.implicitWidth + 24) + hoverEnabled: true + opacity: enabled ? 1.0 : 0.45 + Accessible.name: text + + background: Rectangle { + radius: 6 + color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor) + : (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent") + border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0 + border.color: root.visualFocus ? theme.primary : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + RowLayout { + id: row + anchors.centerIn: parent + spacing: 6 + BusyIndicator { + visible: root.busy + running: root.busy + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + } + Icon { + visible: root.iconName !== "" && !root.busy + name: root.iconName + color: root.labelColor + size: 16 + } + Text { + text: root.text + color: root.labelColor + font.pixelSize: 13 + font.weight: root.solid ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -0,0 +1,10 @@ +module BecUi +Icon 1.0 Icon.qml +IconButton 1.0 IconButton.qml +TextButton 1.0 TextButton.qml +StatusPill 1.0 StatusPill.qml +Card 1.0 Card.qml +FieldFrame 1.0 FieldFrame.qml +InputField 1.0 InputField.qml +SelectField 1.0 SelectField.qml +SwitchField 1.0 SwitchField.qml diff --git a/bec_widgets/utils/ux_kit.py b/bec_widgets/utils/ux_kit.py new file mode 100644 index 00000000..11719f28 --- /dev/null +++ b/bec_widgets/utils/ux_kit.py @@ -0,0 +1,326 @@ +"""Small themed QWidget controls used by the QWidget twins of the QML widget ports. + +They mirror the ``BecUi`` QML controls in ``bec_widgets/utils/quick/qml`` so that the QML and +QWidget versions of a widget look and behave the same and can be compared side by side. Colours +come from the same :class:`~bec_widgets.utils.quick.host.ThemeTokens`. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QEasingCurve, QPropertyAnimation, QRectF, QSize, Qt +from qtpy.QtGui import QColor, QFont, QPainter +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens + + +def rgba(color: QColor, alpha: float) -> str: + """Return a QSS ``rgba()`` string for ``color`` with alpha in 0..1.""" + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})" + + +def refresh_kit_theme(root: QWidget) -> ThemeTokens: + """Re-theme every kit control below ``root`` and return the current tokens.""" + tokens = ThemeTokens() + for widget in [root, *root.findChildren(QWidget)]: + refresh = getattr(widget, "refresh_theme", None) + if callable(refresh) and widget is not root: + refresh(tokens) + return tokens + + +def field_qss(tokens: ThemeTokens) -> str: + """Stylesheet for inputs inside kit cards; ``invalid=true`` marks a field with an error.""" + return f""" + QLineEdit, QComboBox, QSpinBox, QDoubleSpinBox {{ + background: {tokens.field.name()}; + color: {tokens.fg.name()}; + border: 1px solid {tokens.border.name()}; + border-radius: 6px; + padding: 0px 8px; + min-height: 30px; + font-size: 13px; + selection-background-color: {tokens.primary.name()}; + }} + QLineEdit:hover, QComboBox:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ + border-color: {tokens.fg_subtle.name()}; + }} + QLineEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ + border: 2px solid {tokens.primary.name()}; + padding: 0px 7px; + }} + QLineEdit[invalid="true"], QComboBox[invalid="true"] {{ + border: 2px solid {tokens.danger.name()}; + padding: 0px 7px; + }} + QLineEdit:disabled, QComboBox:disabled {{ color: {tokens.fg_subtle.name()}; }} + QComboBox::drop-down {{ border: none; width: 22px; }} + """ + + +def set_invalid(widget: QWidget, invalid: bool) -> None: + """Toggle the ``invalid`` style of a field styled with :func:`field_qss`.""" + if bool(widget.property("invalid")) == invalid: + return + widget.setProperty("invalid", invalid) + widget.style().unpolish(widget) + widget.style().polish(widget) + + +class IconButton(QToolButton): + """Flat square icon button with hover feedback, matching ``BecUi.IconButton``.""" + + def __init__(self, icon_name: str, tip: str = "", parent: QWidget | None = None, size=18): + super().__init__(parent) + self._icon_name = icon_name + self._icon_size = size + self.setToolTip(tip) + self.setAccessibleName(tip) + self.setAutoRaise(True) + self.setFixedSize(30, 30) + self.setIconSize(QSize(size, size)) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_icon_name(self, icon_name: str) -> None: + """Change the Material icon.""" + self._icon_name = icon_name + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + color = tokens.primary if self.isChecked() else tokens.fg_muted + self.setIcon( + material_icon(self._icon_name, size=(48, 48), color=color, convert_to_pixmap=False) + ) + self.setStyleSheet( + f"QToolButton {{ border: none; border-radius: 6px; background: transparent; }}" + f"QToolButton:hover, QToolButton:checked {{ background: {tokens.hover.name()}; }}" + f"QToolButton:pressed {{ background: {tokens.pressed.name()}; }}" + ) + + +class TextButton(QPushButton): + """Labelled button; variant is ``primary``, ``danger``, ``success``, ``neutral`` or ``ghost``.""" + + def __init__( + self, + text: str, + variant: str = "neutral", + icon_name: str = "", + parent: QWidget | None = None, + ): + super().__init__(text, parent) + self._variant = variant + self._icon_name = icon_name + self.setMinimumHeight(32) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.refresh_theme(ThemeTokens()) + + def set_variant(self, variant: str) -> None: + """Switch the colour variant.""" + self._variant = variant + self.refresh_theme(ThemeTokens()) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + base = {"primary": tokens.primary, "danger": tokens.danger, "success": tokens.success}.get( + self._variant + ) + if base is not None: + label = QColor("white") + qss = ( + f"QPushButton {{ background: {base.name()}; color: white; border: none;" + f" border-radius: 6px; padding: 0 12px; font-size: 13px; font-weight: 600; }}" + f"QPushButton:hover {{ background: {base.lighter(110).name()}; }}" + f"QPushButton:pressed {{ background: {base.darker(125).name()}; }}" + f"QPushButton:disabled {{ background: {rgba(base, 0.45)};" + f" color: {rgba(label, 0.7)}; }}" + ) + else: + label = tokens.fg + border = tokens.border.name() if self._variant == "neutral" else "transparent" + qss = ( + f"QPushButton {{ background: transparent; color: {tokens.fg.name()};" + f" border: 1px solid {border}; border-radius: 6px; padding: 0 12px;" + f" font-size: 13px; font-weight: 500; }}" + f"QPushButton:hover {{ background: {tokens.hover.name()}; }}" + f"QPushButton:pressed {{ background: {tokens.pressed.name()}; }}" + f"QPushButton:disabled {{ color: {tokens.fg_subtle.name()}; }}" + ) + self.setStyleSheet(qss) + if self._icon_name: + self.setIcon( + material_icon(self._icon_name, size=(32, 32), color=label, convert_to_pixmap=False) + ) + self.setIconSize(QSize(16, 16)) + + +class StatusPill(QWidget): + """Rounded status chip with a coloured dot, matching ``BecUi.StatusPill``.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self._text = "" + self._tone = QColor("#808080") + self._dot_opacity = 1.0 + self._pulse = QPropertyAnimation(self, b"dot_opacity", self) + self._pulse.setDuration(1100) + self._pulse.setKeyValueAt(0.0, 1.0) + self._pulse.setKeyValueAt(0.5, 0.25) + self._pulse.setKeyValueAt(1.0, 1.0) + self._pulse.setEasingCurve(QEasingCurve.Type.InOutQuad) + self._pulse.setLoopCount(-1) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + font = self.font() + font.setPixelSize(12) + font.setWeight(QFont.Weight.DemiBold) + self.setFont(font) + + def set_status(self, text: str, tone: QColor, pulse: bool = False) -> None: + """Set label, colour and whether the dot pulses.""" + self._text = text + self._tone = QColor(tone) + if pulse and self._pulse.state() != QPropertyAnimation.State.Running: + self._pulse.start() + elif not pulse: + self._pulse.stop() + self._dot_opacity = 1.0 + self.setToolTip(text) + self.updateGeometry() + self.update() + + @property + def text(self) -> str: + """Current label.""" + return self._text + + @property + def pulsing(self) -> bool: + """Whether the dot animation runs.""" + return self._pulse.state() == QPropertyAnimation.State.Running + + def _get_dot_opacity(self) -> float: + return self._dot_opacity + + def _set_dot_opacity(self, value: float) -> None: + self._dot_opacity = value + self.update() + + dot_opacity = Property(float, _get_dot_opacity, _set_dot_opacity) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + return QSize(self.fontMetrics().horizontalAdvance(self._text) + 26, 22) + + def paintEvent(self, _event): # pylint: disable=invalid-name + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + background = QColor(self._tone) + background.setAlphaF(0.16) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(background) + painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2) + dot = QColor(self._tone) + dot.setAlphaF(self._dot_opacity) + painter.setBrush(dot) + painter.drawEllipse(QRectF(9, rect.height() / 2 - 3.5, 7, 7)) + painter.setPen(self._tone) + painter.drawText(rect.adjusted(22, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, self._text) + painter.end() + + def cleanup(self) -> None: + """Stop the pulse animation.""" + self._pulse.stop() + + +class Card(QFrame): + """Rounded surface with an optional title row, matching ``BecUi.Card``.""" + + def __init__(self, title: str = "", parent: QWidget | None = None, padding: int = 12): + super().__init__(parent) + self.setObjectName("uxCard") + outer = QVBoxLayout(self) + outer.setContentsMargins(padding, padding, padding, padding) + outer.setSpacing(10) + self.header = QHBoxLayout() + self.header.setSpacing(8) + self.title_label = QLabel(title, self) + self.subtitle_label = QLabel("", self) + self.subtitle_label.setVisible(False) + self.header.addWidget(self.title_label) + self.header.addWidget(self.subtitle_label, 1) + self.header_extras = QHBoxLayout() + self.header_extras.setSpacing(4) + self.header.addLayout(self.header_extras) + outer.addLayout(self.header) + self.body = QVBoxLayout() + self.body.setSpacing(8) + outer.addLayout(self.body) + self._has_title = bool(title) + self._update_header_visibility() + self.refresh_theme(ThemeTokens()) + + def set_title(self, title: str, subtitle: str = "") -> None: + """Set the title and an optional muted subtitle.""" + self._has_title = bool(title) + self.title_label.setText(title) + self.subtitle_label.setText(subtitle) + self.subtitle_label.setVisible(bool(subtitle)) + self._update_header_visibility() + + def add_header_widget(self, widget: QWidget) -> None: + """Add a widget to the right end of the title row.""" + self.header_extras.addWidget(widget) + self._has_title = True + self._update_header_visibility() + + def _update_header_visibility(self) -> None: + self.title_label.setVisible(bool(self.title_label.text())) + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens.""" + self.setStyleSheet( + f"QFrame#uxCard {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + ) + self.title_label.setStyleSheet( + f"color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; background: none;" + ) + self.subtitle_label.setStyleSheet( + f"color: {tokens.fg_subtle.name()}; font-size: 12px; background: none;" + ) + + +class PortedPropertiesMixin: + """Make a port export and load the ``SafeProperty`` settings of the class it ports. + + ``BECConnector`` only collects the properties defined directly on ``type(self)``, so a + subclass would silently drop the inherited ones (e.g. ``ring_json``) from saved profiles. + Set ``PORTED_FROM`` to the original class; its properties and those of every class in + between are collected. + """ + + PORTED_FROM: type | None = None + + def _get_bec_meta_objects(self) -> dict: + objects = {} + for cls in type(self).__mro__: + for name, attr in vars(cls).items(): + if name in objects or not isinstance(attr, Property): + continue + if getattr(attr.fget, "__is_safe_getter__", False): + objects[name] = attr + if cls is self.PORTED_FROM: + break + return objects diff --git a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py index 1c73651d..ca638e33 100644 --- a/bec_widgets/widgets/containers/dock_area/basic_dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/basic_dock_area.py @@ -17,6 +17,11 @@ from bec_widgets.utils.bec_connector import BECConnector from bec_widgets.utils.property_editor import PropertyEditor from bec_widgets.utils.rpc_widget_handler import widget_handler from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.dock_area.chrome.ads_style import ( + apply_dock_chrome_style, + chrome_mode, + configure_ads_flags, +) from bec_widgets.widgets.containers.qt_ads import ( CDockAreaWidget, CDockManager, @@ -101,9 +106,12 @@ class DockAreaWidget(BECWidget, QWidget): self._root_layout.setContentsMargins(0, 0, 0, 0) self._root_layout.setSpacing(0) + self._chrome_mode = chrome_mode() + configure_ads_flags(self._chrome_mode != "legacy") self.dock_manager = CDockManager(self) self.dock_manager.setStyleSheet("") self.dock_manager.setProperty("variant", variant) + apply_dock_chrome_style(self.dock_manager, self._chrome_mode != "legacy") self._locked = False self._default_add_direction = ( @@ -158,7 +166,14 @@ class DockAreaWidget(BECWidget, QWidget): """Launch the property editor dialog for the dock's widget.""" dlg = DockSettingsDialog(self, widget) dlg.resize(600, 600) - dlg.exec() + if self._chrome_mode == "legacy": + dlg.exec() + return + # Non-modal, so the widget stays visible and usable while its settings change + dlg.setWindowTitle(f"Settings · {dock.windowTitle()}") + dlg.setModal(False) + dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) + dlg.show() ################################################################################ # Dock Lifecycle @@ -1480,6 +1495,15 @@ class DockAreaWidget(BECWidget, QWidget): for dock in self.dock_list(): self._delete_dock(dock) + @SafeSlot(str) + def apply_theme(self, theme: str): # pylint: disable=unused-argument + """Re-apply the dock chrome colours after a theme change. + + Args: + theme(str): Name of the new theme. + """ + apply_dock_chrome_style(self.dock_manager, self._chrome_mode != "legacy") + def cleanup(self): """Tear down all docks via the Qt ADS API before the base BECWidget cleanup runs. diff --git a/bec_widgets/widgets/containers/dock_area/chrome/__init__.py b/bec_widgets/widgets/containers/dock_area/chrome/__init__.py new file mode 100644 index 00000000..d57bcb4f --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/__init__.py @@ -0,0 +1,308 @@ +"""Improved chrome of the BEC dock area. + +:class:`DockChrome` adds to a ``BECDockArea``: + +* human dock titles (``Motor``, ``Waveform 2``) that can be renamed by double-clicking the tab, +* tab-only close with an Undo bar instead of deleting a whole group at once, +* one Add widget gallery with search and a placement choice (Ctrl+Shift+A), +* an empty state with starter layouts. + +The gallery and the empty state come in a QWidget and a QML version; ``BEC_DOCK_CHROME`` picks +one (see :func:`~.ads_style.chrome_mode`). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from qtpy.QtCore import QEvent, QObject, Qt, QTimer +from qtpy.QtGui import QKeySequence, QShortcut +from qtpy.QtWidgets import QApplication, QWidget +from shiboken6 import isValid + +from bec_widgets.widgets.containers.dock_area.chrome.ads_style import ( + apply_dock_chrome_style, + chrome_mode, + configure_ads_flags, +) +from bec_widgets.widgets.containers.dock_area.chrome.catalog import ( + STARTER_LAYOUTS, + display_name, + entry_for, + unique_title, +) +from bec_widgets.widgets.containers.dock_area.chrome.gallery_common import GalleryController +from bec_widgets.widgets.containers.dock_area.chrome.tab_rename import TabRenameFilter +from bec_widgets.widgets.containers.dock_area.chrome.undo_close import PendingCloses, UndoBar +from bec_widgets.widgets.containers.qt_ads import CDockWidget + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + +__all__ = ["DockChrome", "chrome_mode", "configure_ads_flags", "apply_dock_chrome_style"] + + +class DockChrome(QObject): + """Owns the gallery, empty state, undo bar and tab renaming of one dock area. + + Args: + area(BECDockArea): The dock area to decorate. + mode(str): ``qwidget`` or ``qml``. + """ + + def __init__(self, area: "BECDockArea", mode: str = "qwidget"): + super().__init__(area) + self.area = area + self.mode = mode + self.controller = GalleryController(parent=self) + self.controller.widget_requested.connect(self.add_widget) + self.controller.layout_requested.connect(self.build_layout) + self.pending = PendingCloses(area._delete_dock, self) + self.pending.changed.connect(self.refresh_empty_state) + self._batches: list[list[CDockWidget]] = [] + self._batch_open = False + self._last_dock: CDockWidget | None = None + self.gallery = None + + self.undo_bar = UndoBar(area) + self.undo_bar.undo_requested.connect(self.undo_close) + self.undo_bar.dismissed.connect(self.pending.flush) + self.pending.timer.timeout.connect(self.undo_bar.hide) + + self.renamer = TabRenameFilter( + self.rename_dock, lambda: not self.area.workspace_is_locked, self + ) + + if mode == "qml": + from bec_widgets.widgets.containers.dock_area.chrome.chrome_qml import EmptyStateQml + + self.empty_state = EmptyStateQml(self.controller, area) + else: + from bec_widgets.widgets.containers.dock_area.chrome.chrome_qwidget import ( + EmptyStateQWidget, + ) + + self.empty_state = EmptyStateQWidget(self.controller, area) + self.empty_state.add_requested.connect(lambda: self.open_gallery(None)) + self.empty_state.hide() + + self._add_shortcut = QShortcut(QKeySequence("Ctrl+Shift+A"), area) + self._add_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + self._add_shortcut.activated.connect(lambda: self.open_gallery(None)) + self._undo_shortcut = QShortcut(QKeySequence("Ctrl+Z"), area) + self._undo_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut) + self._undo_shortcut.setEnabled(False) + self._undo_shortcut.activated.connect(self.undo_close) + + manager = area.dock_manager + manager.installEventFilter(self) + manager.dockWidgetAdded.connect(self._on_dock_added) + manager.dockWidgetRemoved.connect(lambda *_: self.refresh_empty_state()) + manager.stateRestored.connect(self.refresh_empty_state) + manager.focusedDockWidgetChanged.connect(self._on_focus_changed) + apply_dock_chrome_style(manager, True) + QTimer.singleShot(0, self.refresh_empty_state) + + # ------------------------------------------------------------------ titles + def init_dock(self, dock: CDockWidget, widget: QWidget) -> None: + """Give a new dock its human title and enable renaming on its tab.""" + if not getattr(dock, "_custom_title", False): + taken = {d.windowTitle() for d in self.area.dock_list() if d is not dock} + dock.setWindowTitle(unique_title(display_name(type(widget).__name__), taken)) + self.renamer.watch(dock) + + def rename_dock(self, dock: CDockWidget, title: str) -> None: + """Set the title shown on ``dock``'s tab and keep it when the profile is saved.""" + title = title.strip() + if not title or not isValid(dock): + return + dock.setWindowTitle(title) + dock._custom_title = True # pylint: disable=protected-access + self.renamer.watch(dock) + + # ------------------------------------------------------------------ close with undo + def close_dock(self, dock: CDockWidget) -> None: + """Hide ``dock`` and offer Undo; it is deleted when the undo time runs out.""" + if not self._batch_open: + self._batches.append([]) + self._batch_open = True + QTimer.singleShot(0, self._end_batch) + self._batches[-1].append(dock) + self.pending.close(dock) + batch = self._batches[-1] + if len(batch) == 1: + text = f"Closed “{dock.windowTitle()}”" + else: + text = f"Closed {len(batch)} widgets" + self.undo_bar.show_message(text) + self._undo_shortcut.setEnabled(True) + + def _end_batch(self) -> None: + self._batch_open = False + + def undo_close(self) -> None: + """Bring back the widgets closed last.""" + if not self._batches: + self.undo_bar.hide() + return + batch = self._batches.pop() + restored = None + for dock in reversed(batch): + if dock in self.pending and dock is self.pending.last: + restored = self.pending.undo() + self._batches = [[d for d in b if d in self.pending] for b in self._batches] + self._batches = [b for b in self._batches if b] + if restored is not None and isValid(restored): + self.area.dock_manager.setDockWidgetFocused(restored) + if not self._batches: + self.undo_bar.hide() + self._undo_shortcut.setEnabled(False) + self.refresh_empty_state() + + def flush(self) -> None: + """Delete all closed docks now, e.g. before saving or rebuilding the layout.""" + self._batches = [] + self.pending.flush() + self.undo_bar.hide() + self._undo_shortcut.setEnabled(False) + + # ------------------------------------------------------------------ adding widgets + def anchor_dock(self) -> CDockWidget | None: + """Dock that Beside, Below and As tab refer to: the focused one, else the last added.""" + visible = [d for d in self.area.dock_list() if not d.isClosed()] + focused = self.area.dock_manager.focusedDockWidget() + for candidate in (focused, self._last_dock): + if candidate is not None and isValid(candidate) and candidate in visible: + if not candidate.isFloating(): + return candidate + docked = [d for d in visible if not d.isFloating()] + return docked[-1] if docked else None + + def open_gallery(self, anchor_widget: QWidget | None = None) -> None: + """Open the Add widget gallery below ``anchor_widget`` (centred when None).""" + if self.area.workspace_is_locked: + return + anchor = self.anchor_dock() + self.controller.set_anchor_title(anchor.windowTitle() if anchor is not None else "") + if self.gallery is None: + if self.mode == "qml": + from bec_widgets.widgets.containers.dock_area.chrome.chrome_qml import ( + WidgetGalleryQmlPopup, + ) + + self.gallery = WidgetGalleryQmlPopup(self.controller, self.area) + else: + from bec_widgets.widgets.containers.dock_area.chrome.chrome_qwidget import ( + WidgetGalleryPopup, + ) + + self.gallery = WidgetGalleryPopup(self.controller, self.area) + self.gallery.open_at(anchor_widget) + + def add_widget(self, widget_class: str, placement: str = "beside", anchor=None): + """Add ``widget_class`` at ``placement`` relative to the anchor dock. + + Args: + widget_class(str): Registered widget class name. + placement(str): ``beside``, ``below``, ``tab``, ``column`` or ``floating``. + anchor(CDockWidget | None): Reference dock; defaults to :meth:`anchor_dock`. + + Returns: + The created widget, or None when creation failed. + """ + entry = entry_for(widget_class) + kwargs = dict(entry.new_kwargs) if entry is not None else {} + anchor = anchor if anchor is not None else self.anchor_dock() + if placement == "floating": + kwargs["start_floating"] = True + elif anchor is None or placement == "column": + kwargs["where"] = "right" + elif placement == "beside": + kwargs.update(where="right", relative_to=anchor) + elif placement == "below": + kwargs.update(where="bottom", relative_to=anchor) + elif placement == "tab": + kwargs["tab_with"] = anchor + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + widget = self.area.new(widget_class, **kwargs) + finally: + QApplication.restoreOverrideCursor() + dock = self.dock_of(widget) + if dock is not None: + dock.setAsCurrentTab() + self.area.dock_manager.setDockWidgetFocused(dock) + return widget + + def build_layout(self, name: str) -> None: + """Fill the dock area with the starter layout called ``name``.""" + layout = next((item for item in STARTER_LAYOUTS if item.name == name), None) + if layout is None: + return + docks: list[CDockWidget | None] = [] + for widget_class, placement, anchor_index in layout.steps: + anchor = docks[anchor_index] if anchor_index is not None else None + widget = self.add_widget(widget_class, placement, anchor=anchor) + docks.append(self.dock_of(widget)) + + def dock_of(self, widget) -> CDockWidget | None: + """Return the dock that holds ``widget``.""" + if widget is None or not isinstance(widget, QWidget): + return None + for dock in self.area.dock_list(): + if dock.widget() is widget: + return dock + return None + + # ------------------------------------------------------------------ empty state + def _on_dock_added(self, dock: CDockWidget) -> None: + self._last_dock = dock + self.refresh_empty_state() + + def _on_focus_changed(self, _old, new) -> None: + if new is not None and isValid(new): + self._last_dock = new + + def refresh_empty_state(self) -> None: + """Show the empty state when no widget is left in the dock area.""" + if not isValid(self.empty_state): + return + empty = not any(not d.isClosed() for d in self.area.dock_list()) + if empty: + self.empty_state.setGeometry(self.area.dock_manager.geometry()) + self.empty_state.show() + self.empty_state.raise_() + if self.undo_bar.isVisible(): + self.undo_bar.raise_() + else: + self.empty_state.hide() + + def eventFilter(self, watched, event): # pylint: disable=invalid-name + """Keep the empty state over the dock manager when it moves or resizes.""" + if watched is self.area.dock_manager and event.type() in ( + QEvent.Type.Resize, + QEvent.Type.Move, + ): + if self.empty_state.isVisible(): + self.empty_state.setGeometry(self.area.dock_manager.geometry()) + return super().eventFilter(watched, event) + + # ------------------------------------------------------------------ theme / teardown + def refresh_theme(self) -> None: + """Re-apply theme colours to the chrome.""" + apply_dock_chrome_style(self.area.dock_manager, True) + self.undo_bar.refresh_theme() + self.empty_state.refresh_theme() + if self.gallery is not None: + self.gallery.refresh_theme() + + def cleanup(self) -> None: + """Delete pending docks and unload QML scenes.""" + self.flush() + for view in (self.gallery, self.empty_state): + if view is not None and hasattr(view, "cleanup"): + view.cleanup() + if self.gallery is not None: + self.gallery.close() + self.gallery.deleteLater() + self.gallery = None diff --git a/bec_widgets/widgets/containers/dock_area/chrome/ads_style.py b/bec_widgets/widgets/containers/dock_area/chrome/ads_style.py new file mode 100644 index 00000000..047bdd14 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/ads_style.py @@ -0,0 +1,139 @@ +"""Qt-ADS behaviour flags and the stylesheet layer of the improved dock area chrome. + +The dock framework stays Qt-ADS. This module only changes how it behaves and looks: + +* the per-group X (which closed every tab of a group at once) is gone; every tab has its own X, +* double-clicking a title no longer pops the widget out (double-click renames instead), +* the tabs menu only shows when tabs overflow, +* splitter gutters are visible and highlight on hover, +* the focused group and the active tab are marked with the accent colour, +* drop overlays use the theme's accent instead of Qt-ADS' default blue. +""" + +from __future__ import annotations + +import os + +from qtpy.QtGui import QColor, QPalette +from qtpy.QtWidgets import QWidget + +import bec_widgets.widgets.containers.qt_ads as QtAds +from bec_widgets.utils.quick.host import ThemeTokens + +CHROME_ENV = "BEC_DOCK_CHROME" +CHROME_MODES = ("qwidget", "qml", "legacy") + +_FLAG_SNAPSHOT: dict | None = None + + +def chrome_mode() -> str: + """Return the selected dock chrome: ``qwidget`` (default), ``qml`` or ``legacy``. + + Set ``BEC_DOCK_CHROME=qml`` to use the QML gallery and empty state, or ``legacy`` to get the + dock area exactly as it was before. + """ + mode = os.environ.get(CHROME_ENV, "qwidget").strip().lower() + return mode if mode in CHROME_MODES else "qwidget" + + +def _modern_flags() -> dict: + flag = QtAds.CDockManager.eConfigFlag + return { + flag.DockAreaHasCloseButton: False, + flag.DockAreaCloseButtonClosesTab: True, + flag.AllTabsHaveCloseButton: True, + flag.DoubleClickUndocksWidget: False, + flag.DockAreaDynamicTabsMenuButtonVisibility: True, + flag.MiddleMouseButtonClosesTab: True, + flag.FloatingContainerHasWidgetTitle: True, + flag.FocusHighlighting: True, + } + + +def configure_ads_flags(modern: bool) -> None: + """Set the global Qt-ADS config flags. Must run before a ``CDockManager`` is created. + + Args: + modern(bool): True for the improved behaviour, False to restore the flags found on the + first call (the legacy behaviour). + """ + global _FLAG_SNAPSHOT # pylint: disable=global-statement + manager = QtAds.CDockManager + if _FLAG_SNAPSHOT is None: + _FLAG_SNAPSHOT = { + key: manager.testConfigFlag(key) for key in _modern_flags() # type: ignore[arg-type] + } + target = _modern_flags() if modern else _FLAG_SNAPSHOT + for key, value in target.items(): + manager.setConfigFlag(key, value) + + +def _rgba(color: QColor, alpha: float) -> str: + return f"rgba({color.red()}, {color.green()}, {color.blue()}, {int(alpha * 255)})" + + +def _line(color: str, horizontal_handle: bool) -> str: + """A 2 px line centred in a splitter handle, drawn with a hard-stop gradient.""" + axis = "x1:0, y1:0, x2:1, y2:0" if horizontal_handle else "x1:0, y1:0, x2:0, y2:1" + return ( + f"qlineargradient({axis}, stop:0 transparent, stop:0.37 transparent," + f" stop:0.38 {color}, stop:0.62 {color}, stop:0.63 transparent, stop:1 transparent)" + ) + + +def dock_chrome_qss(tokens: ThemeTokens) -> str: + """Stylesheet applied on top of the application theme to the dock manager. + + Args: + tokens(ThemeTokens): Resolved colours of the active theme. + """ + grip = _rgba(tokens.fg, 0.16) + grip_hover = tokens.primary.name() + overlay_colors = " ".join( + ( + f"Frame={tokens.primary.name()}", + f"WindowBackground={tokens.card.name()}", + f"Overlay={_rgba(tokens.primary, 0.3)}", + f"Arrow={tokens.fg.name()}", + f"Shadow={_rgba(QColor('black'), 0.25)}", + ) + ) + return f""" + ads--CDockSplitter::handle:horizontal {{ background: {_line(grip, True)}; }} + ads--CDockSplitter::handle:vertical {{ background: {_line(grip, False)}; }} + ads--CDockSplitter::handle:horizontal:hover {{ background: {_line(grip_hover, True)}; }} + ads--CDockSplitter::handle:vertical:hover {{ background: {_line(grip_hover, False)}; }} + ads--CDockSplitter::handle:pressed {{ background: {_rgba(tokens.primary, 0.55)}; }} + + ads--CDockAreaWidget[focused="true"] {{ border: 1px solid {_rgba(tokens.primary, 0.7)}; }} + + ads--CDockWidgetTab {{ padding: 2px 4px; }} + ads--CDockWidgetTab QLabel {{ color: {tokens.fg_muted.name()}; }} + ads--CDockWidgetTab:hover QLabel {{ color: {tokens.fg.name()}; }} + ads--CDockWidgetTab[activeTab="true"] QLabel {{ color: {tokens.fg.name()}; font-weight: 600; }} + ads--CDockWidgetTab[activeTab="true"] {{ border-bottom: 2px solid {tokens.fg_subtle.name()}; }} + ads--CDockWidgetTab[focused="true"] {{ border-bottom: 2px solid {tokens.primary.name()}; }} + + ads--CDockOverlayCross {{ qproperty-iconColors: "{overlay_colors}"; }} + """ + + +def apply_dock_chrome_style(manager: QWidget, enabled: bool) -> None: + """Apply (or clear) the chrome stylesheet and drop-overlay colours on a dock manager. + + Args: + manager(QWidget): The ``CDockManager``. + enabled(bool): False clears the layer and keeps only the application theme. + """ + if not enabled: + manager.setStyleSheet("") + return + tokens = ThemeTokens() + manager.setStyleSheet(dock_chrome_qss(tokens)) + for name in ("dockAreaOverlay", "containerOverlay"): + overlay = getattr(manager, name, lambda: None)() + if overlay is None: + continue + palette = overlay.palette() + palette.setColor(QPalette.ColorRole.Highlight, tokens.primary) + overlay.setPalette(palette) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/catalog.py b/bec_widgets/widgets/containers/dock_area/chrome/catalog.py new file mode 100644 index 00000000..4dba8040 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/catalog.py @@ -0,0 +1,319 @@ +"""Human names, categories and descriptions of the widgets offered by the dock area. + +The Python class names stay the API (RPC, profiles, object names). Everything the user reads in +the dock area, the dock titles and the Add widget gallery comes from this catalog instead. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +CATEGORIES = ("Plots", "Device control", "Scans & status", "Tools", "Plugins") + +Placement = Literal["beside", "below", "tab", "column", "floating"] + +PLACEMENTS: tuple[tuple[str, str, str], ...] = ( + ("beside", "Beside", "Right of the selected widget"), + ("below", "Below", "Under the selected widget"), + ("tab", "As tab", "In the same group as the selected widget"), + ("column", "New column", "A new column at the right edge"), + ("floating", "Floating", "In its own window"), +) + + +@dataclass(frozen=True) +class WidgetEntry: + """One widget the user can add to a dock area. + + Args: + widget_class(str): Registered class name used with ``BECDockArea.new``. + name(str): Human name shown in the gallery and used for dock titles. + category(str): One of :data:`CATEGORIES`. + description(str): One line saying what the widget is for. + icon(str): Material icon name. + keywords(tuple[str, ...]): Extra search words, e.g. today's or older names. + new_kwargs(dict): Extra keyword arguments passed to ``new``. + """ + + widget_class: str + name: str + category: str + description: str + icon: str + keywords: tuple[str, ...] = () + new_kwargs: dict = field(default_factory=dict, hash=False, compare=False) + + def matches(self, query: str) -> bool: + """Return True when every word of ``query`` occurs in the name, description or keywords.""" + words = query.lower().split() + if not words: + return True + haystack = " ".join( + (self.name, self.description, self.widget_class, self.category, *self.keywords) + ).lower() + return all(word in haystack for word in words) + + +CORE_WIDGETS: tuple[WidgetEntry, ...] = ( + WidgetEntry( + "Waveform", + "Waveform", + "Plots", + "Signals against a motor or time, with fits", + "show_chart", + ("plot", "curve", "line", "1d"), + ), + WidgetEntry( + "ScatterWaveform", + "Scatter Plot", + "Plots", + "Points coloured by a third signal", + "scatter_plot", + ("scatter waveform", "2d"), + ), + WidgetEntry( + "MultiWaveform", + "Multi Waveform", + "Plots", + "Many traces of one monitor, newest on top", + "ssid_chart", + ("traces", "history"), + ), + WidgetEntry( + "Image", + "Image", + "Plots", + "Live camera or detector image", + "image", + ("camera", "detector", "2d"), + ), + WidgetEntry( + "Heatmap", + "Heatmap", + "Plots", + "Grid scans as a colour map", + "dataset", + ("grid", "map", "2d"), + ), + WidgetEntry( + "MotorMap", + "Motor Map", + "Plots", + "Trail of two motors' positions", + "my_location", + ("trajectory", "xy"), + ), + WidgetEntry( + "ScanControl", + "Scan Control", + "Device control", + "Set up, start and stop scans", + "tune", + ("start", "scan", "acquire"), + ), + WidgetEntry( + "PositionerBox", + "Motor", + "Device control", + "Move one motor and watch its position", + "switch_right", + ("device box", "positioner", "move"), + ), + WidgetEntry( + "PositionerBox2D", + "2D Motor", + "Device control", + "Move two motors with a joystick pad", + "open_with", + ("device 2d box", "positioner", "xy"), + ), + WidgetEntry( + "BECQueue", + "Scan Queue", + "Scans & status", + "Running and waiting scans", + "edit_note", + ("queue",), + ), + WidgetEntry( + "ScanProgressBar", + "Scan Progress", + "Scans & status", + "Progress and time left of the running scan", + "timelapse", + ("progress", "bar"), + ), + WidgetEntry( + "RingProgressBar", + "Ring Progress", + "Scans & status", + "Concentric rings for scan and device progress", + "track_changes", + ("circular progressbar", "ring"), + ), + WidgetEntry( + "BECStatusBox", + "Service Status", + "Scans & status", + "Health of the BEC services", + "monitor_heart", + ("bec status box", "services"), + ), + WidgetEntry( + "BeamlineStateManager", + "Beamline States", + "Scans & status", + "Beamline conditions and their interlocks", + "format_list_bulleted", + ("beamline state manager", "interlock"), + ), + WidgetEntry( + "LogPanel", + "Logs", + "Tools", + "Messages from the BEC services", + "browse_activity", + ("log panel", "messages"), + ), + WidgetEntry( + "BECShell", + "BEC Command Line", + "Tools", + "The bec client, connected to this window", + "hub", + ("bec shell", "ipython", "console"), + {"show_settings_action": False}, + ), + WidgetEntry( + "BecConsole", + "System Terminal", + "Tools", + "A shell on this computer", + "terminal", + ("terminal", "bash"), + {"startup_cmd": None}, + ), + WidgetEntry( + "SBBMonitor", + "Departures", + "Tools", + "Bus and train departures near PSI", + "train", + ("sbb monitor", "train", "bus"), + ), +) + +_BY_CLASS: dict[str, WidgetEntry] = {entry.widget_class: entry for entry in CORE_WIDGETS} + + +def _title_from_class(name: str) -> str: + from bec_widgets.utils.name_utils import pascal_to_title + + return pascal_to_title(name) + + +def plugin_entries() -> tuple[WidgetEntry, ...]: + """Return gallery entries for widgets provided by beamline plugins.""" + try: + from bec_widgets.utils.bec_plugin_helper import ( + get_plugin_rpc_widget_registry, + get_plugin_widget_icons, + ) + from bec_widgets.utils.plugin_utils import get_rpc_widget_registry + + plugin_registry = get_plugin_rpc_widget_registry() + internal_registry = get_rpc_widget_registry() + icons = get_plugin_widget_icons() + except Exception: # pragma: no cover - plugin discovery must never break the dock area + return () + return tuple( + WidgetEntry( + name, + _title_from_class(name), + "Plugins", + "Provided by a beamline plugin", + icons.get(name, "widgets"), + ) + for name in sorted(plugin_registry) + if name not in internal_registry and name not in _BY_CLASS + ) + + +def all_entries(include_plugins: bool = True) -> tuple[WidgetEntry, ...]: + """All widgets offered by the gallery, core widgets first. + + Args: + include_plugins(bool): Whether to add widgets from beamline plugins. + """ + if not include_plugins: + return CORE_WIDGETS + return CORE_WIDGETS + plugin_entries() + + +def entry_for(widget_class: str) -> WidgetEntry | None: + """Return the catalog entry of a core widget class, if there is one.""" + return _BY_CLASS.get(widget_class) + + +def display_name(widget_class: str) -> str: + """Human name of a widget class, e.g. ``PositionerBox`` -> ``Motor``.""" + entry = _BY_CLASS.get(widget_class) + if entry is not None: + return entry.name + return _title_from_class(widget_class) + + +def unique_title(base: str, taken: set[str]) -> str: + """Return ``base``, or ``base 2``, ``base 3``... when that title is already taken.""" + if base not in taken: + return base + index = 2 + while f"{base} {index}" in taken: + index += 1 + return f"{base} {index}" + + +@dataclass(frozen=True) +class StarterLayout: + """A ready-made arrangement offered on an empty workspace. + + ``steps`` is a sequence of ``(widget_class, placement, anchor_index)`` tuples. The anchor is + the index of an earlier step, or ``None`` for the first widget. + """ + + name: str + description: str + icon: str + steps: tuple[tuple[str, Placement, int | None], ...] + + +STARTER_LAYOUTS: tuple[StarterLayout, ...] = ( + StarterLayout( + "Scanning", + "Scan Control, a live plot, the queue and scan progress", + "show_chart", + ( + ("ScanControl", "column", None), + ("Waveform", "beside", 0), + ("BECQueue", "below", 1), + ("ScanProgressBar", "tab", 2), + ), + ), + StarterLayout( + "Alignment", + "A camera image with two motors and their trail", + "my_location", + (("Image", "column", None), ("PositionerBox2D", "beside", 0), ("MotorMap", "below", 1)), + ), + StarterLayout( + "Monitoring", + "Beamline states, service health and logs", + "monitor_heart", + ( + ("BeamlineStateManager", "column", None), + ("BECStatusBox", "below", 0), + ("LogPanel", "beside", 0), + ), + ), +) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py new file mode 100644 index 00000000..b3f74e14 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qml.py @@ -0,0 +1,103 @@ +"""QML version of the dock area chrome: the Add widget gallery and the empty state. + +Both views share :class:`~.gallery_common.GalleryController` with the QWidget version, so search, +highlight, placement and starter layouts behave the same. +""" + +from __future__ import annotations + +from pathlib import Path + +from qtpy.QtCore import QPoint, Qt, Signal +from qtpy.QtWidgets import QFrame, QVBoxLayout, QWidget + +from bec_widgets.utils.quick.host import ThemeTokens, create_quick_widget, release_quick_widget +from bec_widgets.widgets.containers.dock_area.chrome.gallery_common import GalleryController + +QML_DIR = Path(__file__).parent / "qml" + + +class WidgetGalleryQmlPopup(QFrame): + """The Add widget gallery as a popup rendered with Qt Quick. + + Args: + controller(GalleryController): Shared gallery state. + parent(QWidget | None): Parent widget. + """ + + def __init__(self, controller: GalleryController, parent: QWidget | None = None): + super().__init__(parent, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) + self.setObjectName("widgetGalleryQml") + self.controller = controller + self.resize(460, 540) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget( + self, QML_DIR / "WidgetGallery.qml", {"backend": controller} + ) + self.view.setClearColor(ThemeTokens().card) + layout.addWidget(self.view) + root = self.view.rootObject() + if root is not None: + root.closeRequested.connect(self.close) + controller.widget_requested.connect(lambda *_: self.close()) + + def open_at(self, anchor: QWidget | None) -> None: + """Show the gallery below ``anchor`` (or centred on the parent).""" + self.controller.reset() + if anchor is not None: + pos = anchor.mapToGlobal(QPoint(0, anchor.height() + 4)) + else: + parent = self.parentWidget() + center = parent.mapToGlobal(parent.rect().center()) if parent else QPoint(200, 200) + pos = center - QPoint(self.width() // 2, self.height() // 2) + screen = self.screen().availableGeometry() if self.screen() else None + if screen is not None: + pos.setX(max(screen.left(), min(pos.x(), screen.right() - self.width()))) + pos.setY(max(screen.top(), min(pos.y(), screen.bottom() - self.height()))) + self.move(pos) + self.show() + self.view.setFocus(Qt.FocusReason.PopupFocusReason) + root = self.view.rootObject() + if root is not None: + root.focusSearch() + + def refresh_theme(self) -> None: + """The QML scene follows ``theme`` by itself; only the clear colour is updated.""" + self.view.setClearColor(ThemeTokens().card) + + def cleanup(self) -> None: + """Unload the scene before the controller goes away.""" + release_quick_widget(self.view) + + +class EmptyStateQml(QWidget): + """The empty-workspace view rendered with Qt Quick. + + Args: + controller(GalleryController): Shared gallery state (starter layouts). + parent(QWidget | None): Parent widget. + """ + + add_requested = Signal() + + def __init__(self, controller: GalleryController, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("dockEmptyStateQml") + self.controller = controller + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view = create_quick_widget(self, QML_DIR / "EmptyState.qml", {"backend": controller}) + self.view.setClearColor(ThemeTokens().bg) + layout.addWidget(self.view) + root = self.view.rootObject() + if root is not None: + root.addRequested.connect(self.add_requested) + + def refresh_theme(self) -> None: + """The QML scene follows ``theme`` by itself; only the clear colour is updated.""" + self.view.setClearColor(ThemeTokens().bg) + + def cleanup(self) -> None: + """Unload the scene before the controller goes away.""" + release_quick_widget(self.view) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/chrome_qwidget.py b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qwidget.py new file mode 100644 index 00000000..05d22cac --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/chrome_qwidget.py @@ -0,0 +1,497 @@ +"""QWidget version of the dock area chrome: the Add widget gallery and the empty state.""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QEvent, QModelIndex, QPoint, QRect, QSize, Qt, Signal +from qtpy.QtGui import QFont, QPainter, QPainterPath, QPen +from qtpy.QtWidgets import ( + QAbstractItemView, + QButtonGroup, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QSizePolicy, + QStyle, + QStyledItemDelegate, + QStyleOptionViewItem, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import TextButton, field_qss, rgba +from bec_widgets.widgets.containers.dock_area.chrome.gallery_common import GalleryController + +_KIND = Qt.ItemDataRole.UserRole + 1 +_ROW = Qt.ItemDataRole.UserRole + 2 +_DATA = Qt.ItemDataRole.UserRole + 3 + + +class _GalleryDelegate(QStyledItemDelegate): + """Paints category headers and widget rows (icon tile, name, description).""" + + def __init__(self, parent=None): + super().__init__(parent) + self.tokens = ThemeTokens() + self.highlight_row = -1 + + def sizeHint(self, option, index): # pylint: disable=invalid-name + """Headers are short, widget rows are two lines high.""" + if index.data(_KIND) == "header": + return QSize(option.rect.width(), 30) + return QSize(option.rect.width(), 50) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex): + """Paint a category header or a widget row.""" + tokens = self.tokens + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = option.rect + if index.data(_KIND) == "header": + font = QFont(option.font) + font.setPixelSize(11) + font.setWeight(QFont.Weight.DemiBold) + font.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 106) + painter.setFont(font) + painter.setPen(tokens.fg_subtle) + painter.drawText( + rect.adjusted(12, 8, -12, 0), + Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, + str(index.data(Qt.ItemDataRole.DisplayRole)).upper(), + ) + painter.restore() + return + + data = index.data(_DATA) or {} + highlighted = index.data(_ROW) == self.highlight_row + hovered = bool(option.state & QStyle.StateFlag.State_MouseOver) + card = rect.adjusted(4, 1, -4, -1) + if highlighted or hovered: + path = QPainterPath() + path.addRoundedRect(card, 7, 7) + painter.fillPath( + path, tokens.soft(tokens.primary, 0.14) if highlighted else tokens.hover + ) + if highlighted: + painter.setPen(QPen(tokens.soft(tokens.primary, 0.5), 1)) + painter.drawPath(path) + + tile = QRect(card.left() + 8, card.center().y() - 16, 32, 32) + path = QPainterPath() + path.addRoundedRect(tile, 7, 7) + painter.fillPath(path, tokens.soft(tokens.primary, 0.18)) + icon = material_icon( + data.get("iconName", "widgets"), + size=(40, 40), + color=tokens.primary, + convert_to_pixmap=False, + ) + icon.paint(painter, tile.adjusted(7, 7, -7, -7)) + + text_left = tile.right() + 12 + name_font = QFont(option.font) + name_font.setPixelSize(13) + name_font.setWeight(QFont.Weight.DemiBold) + painter.setFont(name_font) + painter.setPen(tokens.fg) + name_rect = QRect(text_left, card.top() + 7, card.right() - text_left - 10, 18) + painter.drawText(name_rect, Qt.AlignmentFlag.AlignLeft, data.get("name", "")) + + desc_font = QFont(option.font) + desc_font.setPixelSize(12) + painter.setFont(desc_font) + painter.setPen(tokens.fg_muted) + desc_rect = QRect(text_left, card.top() + 26, card.right() - text_left - 10, 18) + desc = painter.fontMetrics().elidedText( + data.get("description", ""), Qt.TextElideMode.ElideRight, desc_rect.width() + ) + painter.drawText(desc_rect, Qt.AlignmentFlag.AlignLeft, desc) + painter.restore() + + +class WidgetGalleryPopup(QFrame): + """The Add widget gallery as a popup: search, placement, and the widgets by category. + + Args: + controller(GalleryController): Shared gallery state. + parent(QWidget | None): Parent widget. + """ + + def __init__(self, controller: GalleryController, parent: QWidget | None = None): + super().__init__(parent, Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint) + self.setObjectName("widgetGallery") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.controller = controller + self.resize(460, 540) + + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 10) + layout.setSpacing(10) + + self.search = QLineEdit(self) + self.search.setObjectName("gallerySearch") + self.search.setPlaceholderText("Search widgets, e.g. plot, motor, queue") + self.search.setClearButtonEnabled(True) + self.search.textChanged.connect(controller.set_query) + self.search.installEventFilter(self) + layout.addWidget(self.search) + + place_row = QHBoxLayout() + place_row.setSpacing(4) + place_label = QLabel("Place", self) + place_label.setObjectName("galleryMuted") + place_row.addWidget(place_label) + place_row.addSpacing(4) + self.placement_group = QButtonGroup(self) + self.placement_group.setExclusive(True) + self.placement_buttons: dict[str, QToolButton] = {} + for item in controller.placement_model.items: + button = QToolButton(self) + button.setObjectName("placementButton") + button.setText(item["label"]) + button.setToolTip(item["hint"]) + button.setCheckable(True) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.clicked.connect(lambda _=False, key=item["key"]: controller.set_placement(key)) + self.placement_group.addButton(button) + self.placement_buttons[item["key"]] = button + place_row.addWidget(button) + place_row.addStretch(1) + layout.addLayout(place_row) + + self.summary = QLabel(self) + self.summary.setObjectName("galleryMuted") + layout.addWidget(self.summary) + + self.list = QListWidget(self) + self.list.setObjectName("galleryList") + self.list.setMouseTracking(True) + self.list.setFrameShape(QFrame.Shape.NoFrame) + self.list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection) + self.list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.list.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.delegate = _GalleryDelegate(self.list) + self.list.setItemDelegate(self.delegate) + self.list.itemClicked.connect(self._on_item_clicked) + self.list.entered.connect(self._on_item_entered) + layout.addWidget(self.list, 1) + + self.empty_label = QLabel("No widget matches. Try another word.", self) + self.empty_label.setObjectName("galleryMuted") + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self.empty_label) + + footer = QLabel("↑ ↓ choose · Enter add · Esc close", self) + footer.setObjectName("galleryFooter") + layout.addWidget(footer) + + controller.items.modelReset.connect(self._rebuild) + controller.items.rowsInserted.connect(self._rebuild) + controller.items.rowsRemoved.connect(self._rebuild) + controller.items.dataChanged.connect(self._rebuild) + controller.highlight_changed.connect(self._sync_highlight) + controller.placement_changed.connect(self._sync_placement) + controller.widget_requested.connect(lambda *_: self.close()) + self.refresh_theme() + self._rebuild() + self._sync_placement() + + # ------------------------------------------------------------------ build + def _rebuild(self, *_args) -> None: + self.list.clear() + last_category = None + for row, item in enumerate(self.controller.items.items): + if item["category"] != last_category: + header = QListWidgetItem(item["category"]) + header.setData(_KIND, "header") + header.setFlags(Qt.ItemFlag.NoItemFlags) + self.list.addItem(header) + last_category = item["category"] + entry = QListWidgetItem(item["name"]) + entry.setData(_KIND, "item") + entry.setData(_ROW, row) + entry.setData(_DATA, item) + entry.setToolTip(f"{item['description']}\nClass: {item['widgetClass']}") + self.list.addItem(entry) + self.empty_label.setVisible(self.controller.items.count == 0) + self._sync_highlight() + + def _sync_highlight(self) -> None: + self.delegate.highlight_row = self.controller.highlight + for i in range(self.list.count()): + if self.list.item(i).data(_ROW) == self.controller.highlight: + self.list.scrollToItem(self.list.item(i)) + break + self.list.viewport().update() + + def _sync_placement(self) -> None: + current = self.controller.placement + for item in self.controller.placement_model.items: + button = self.placement_buttons[item["key"]] + button.setEnabled(bool(item["available"])) + button.setChecked(item["key"] == current) + self.summary.setText(f"Goes: {self.controller.placement_summary()}") + + # ------------------------------------------------------------------ interaction + def _on_item_clicked(self, item: QListWidgetItem) -> None: + if item.data(_KIND) == "item": + self.controller.activate(int(item.data(_ROW))) + + def _on_item_entered(self, index: QModelIndex) -> None: + if index.data(_KIND) == "item": + self.controller.set_highlight(int(index.data(_ROW))) + + def eventFilter(self, watched, event): # pylint: disable=invalid-name + """Arrow keys, Enter and Escape in the search field drive the list.""" + if watched is self.search and event.type() == QEvent.Type.KeyPress: + key = event.key() + if key == Qt.Key.Key_Down: + self.controller.move_highlight(1) + return True + if key == Qt.Key.Key_Up: + self.controller.move_highlight(-1) + return True + if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.controller.activate() + return True + if key == Qt.Key.Key_Escape: + self.close() + return True + return super().eventFilter(watched, event) + + def open_at(self, anchor: QWidget | None) -> None: + """Show the gallery below ``anchor`` (or centred on the active window).""" + self.controller.reset() + self.search.clear() + if anchor is not None: + pos = anchor.mapToGlobal(QPoint(0, anchor.height() + 4)) + else: + parent = self.parentWidget() + center = parent.mapToGlobal(parent.rect().center()) if parent else QPoint(200, 200) + pos = center - QPoint(self.width() // 2, self.height() // 2) + screen = self.screen().availableGeometry() if self.screen() else None + if screen is not None: + pos.setX(max(screen.left(), min(pos.x(), screen.right() - self.width()))) + pos.setY(max(screen.top(), min(pos.y(), screen.bottom() - self.height()))) + self.move(pos) + self.show() + self.search.setFocus(Qt.FocusReason.PopupFocusReason) + + def refresh_theme(self) -> None: + """Apply the current theme colours.""" + tokens = ThemeTokens() + self.delegate.tokens = tokens + self.setStyleSheet( + f"#widgetGallery {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + f"#galleryList {{ background: transparent; }}" + f"#galleryMuted {{ color: {tokens.fg_muted.name()}; font-size: 12px; }}" + f"#galleryFooter {{ color: {tokens.fg_subtle.name()}; font-size: 11px; }}" + f"#placementButton {{ color: {tokens.fg.name()}; background: transparent;" + f" border: 1px solid {tokens.border.name()}; border-radius: 6px;" + f" padding: 3px 9px; font-size: 12px; }}" + f"#placementButton:hover {{ background: {tokens.hover.name()}; }}" + f"#placementButton:checked {{ background: {tokens.soft(tokens.primary, 0.2).name()};" + f" border-color: {tokens.primary.name()}; color: {tokens.fg.name()}; }}" + f"#placementButton:disabled {{ color: {tokens.fg_subtle.name()};" + f" border-color: {rgba(tokens.border, 0.5)}; }}" + field_qss(tokens) + ) + self.list.viewport().update() + + +class _LayoutCard(QFrame): + """Clickable card for a starter layout with a small schematic of its arrangement.""" + + clicked = Signal(str) + + def __init__(self, item: dict, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("layoutCard") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setAccessibleName(f"Start from the {item['name']} layout") + self._name = item["name"] + self.setFixedWidth(196) + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(4) + self.preview = _LayoutPreview(item["name"], self) + layout.addWidget(self.preview) + layout.addSpacing(6) + title = QLabel(item["name"], self) + title.setObjectName("layoutTitle") + desc = QLabel(item["description"], self) + desc.setObjectName("layoutDesc") + desc.setWordWrap(True) + layout.addWidget(title) + layout.addWidget(desc) + layout.addStretch(1) + + def mouseReleaseEvent(self, event): # pylint: disable=invalid-name + """A click on the card starts the layout.""" + if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()): + self.clicked.emit(self._name) + super().mouseReleaseEvent(event) + + def keyPressEvent(self, event): # pylint: disable=invalid-name + """Enter or Space on the focused card starts the layout.""" + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Space): + self.clicked.emit(self._name) + return + super().keyPressEvent(event) + + +# Schematic rectangles (x, y, w, h in 0..1) of the starter layouts, matching catalog.STARTER_LAYOUTS +LAYOUT_SCHEMATICS: dict[str, list[tuple[float, float, float, float]]] = { + "Scanning": [(0, 0, 0.36, 1), (0.38, 0, 0.62, 0.58), (0.38, 0.62, 0.62, 0.38)], + "Alignment": [(0, 0, 0.58, 1), (0.6, 0, 0.4, 0.48), (0.6, 0.52, 0.4, 0.48)], + "Monitoring": [(0, 0, 0.4, 0.48), (0, 0.52, 0.4, 0.48), (0.42, 0, 0.58, 1)], +} + + +class _LayoutPreview(QWidget): + def __init__(self, name: str, parent: QWidget | None = None): + super().__init__(parent) + self._name = name + self.tokens = ThemeTokens() + self.setFixedHeight(64) + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Draw the schematic of the layout.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + area = self.rect().adjusted(0, 0, -1, -1) + for x, y, w, h in LAYOUT_SCHEMATICS.get(self._name, []): + rect = QRect( + area.left() + int(x * area.width()), + area.top() + int(y * area.height()), + int(w * area.width()), + int(h * area.height()), + ) + path = QPainterPath() + path.addRoundedRect(rect, 4, 4) + painter.fillPath(path, self.tokens.soft(self.tokens.primary, 0.14)) + painter.setPen(QPen(self.tokens.soft(self.tokens.primary, 0.45), 1)) + painter.drawPath(path) + title_bar = QRect(rect.left() + 5, rect.top() + 5, min(26, rect.width() - 10), 4) + painter.fillRect(title_bar, self.tokens.soft(self.tokens.primary, 0.55)) + + +class EmptyStateQWidget(QWidget): + """What an empty dock area shows: Add widget, three starter layouts and a docking hint. + + Args: + controller(GalleryController): Shared gallery state (starter layouts). + parent(QWidget | None): Parent widget. + """ + + add_requested = Signal() + + def __init__(self, controller: GalleryController, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("dockEmptyState") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.controller = controller + + outer = QVBoxLayout(self) + outer.addStretch(3) + column = QVBoxLayout() + column.setSpacing(8) + column.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + self.icon = QLabel(self) + self.icon.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.icon.setFixedSize(56, 56) + column.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignHCenter) + + title = QLabel("This workspace is empty", self) + title.setObjectName("emptyTitle") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(title) + subtitle = QLabel( + "Add the widgets you need, or start from a layout and change it afterwards.", self + ) + subtitle.setObjectName("emptySubtitle") + subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) + subtitle.setWordWrap(True) + column.addWidget(subtitle) + column.addSpacing(8) + + self.add_button = TextButton("Add widget", "primary", "add", self) + self.add_button.setToolTip("Open the widget gallery (Ctrl+Shift+A)") + self.add_button.setMinimumWidth(150) + self.add_button.clicked.connect(self.add_requested) + column.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignHCenter) + column.addSpacing(18) + + start = QLabel("Or start from a layout", self) + start.setObjectName("emptySection") + start.setAlignment(Qt.AlignmentFlag.AlignCenter) + column.addWidget(start) + + cards = QHBoxLayout() + cards.setSpacing(12) + cards.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cards: list[_LayoutCard] = [] + for item in controller.layouts.items: + card = _LayoutCard(item, self) + card.clicked.connect(controller.activate_layout) + cards.addWidget(card) + self.cards.append(card) + column.addLayout(cards) + column.addSpacing(14) + + hint = QLabel( + "Tip: drag a tab onto the edge of another widget to split the view, " + "or onto its centre to stack them as tabs.", + self, + ) + hint.setObjectName("emptyHint") + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setWordWrap(True) + column.addWidget(hint) + + wrapper = QHBoxLayout() + wrapper.addStretch(1) + holder = QWidget(self) + holder.setLayout(column) + holder.setMaximumWidth(680) + holder.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum) + wrapper.addWidget(holder) + wrapper.addStretch(1) + outer.addLayout(wrapper) + outer.addStretch(4) + self.refresh_theme() + + def refresh_theme(self) -> None: + """Apply the current theme colours.""" + tokens = ThemeTokens() + self.icon.setPixmap( + material_icon("dashboard_customize", size=(30, 30), color=tokens.primary) + ) + self.icon.setStyleSheet( + f"background: {tokens.soft(tokens.primary, 0.16).name()}; border-radius: 28px;" + ) + self.setStyleSheet( + f"#dockEmptyState {{ background: {tokens.bg.name()}; }}" + f"#emptyTitle {{ color: {tokens.fg.name()}; font-size: 18px; font-weight: 600; }}" + f"#emptySubtitle {{ color: {tokens.fg_muted.name()}; font-size: 13px; }}" + f"#emptySection {{ color: {tokens.fg_muted.name()}; font-size: 12px;" + f" font-weight: 600; }}" + f"#emptyHint {{ color: {tokens.fg_subtle.name()}; font-size: 12px; }}" + f"#layoutCard {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 10px; }}" + f"#layoutCard:hover, #layoutCard:focus {{ border-color: {tokens.primary.name()}; }}" + f"#layoutTitle {{ color: {tokens.fg.name()}; font-size: 13px; font-weight: 600; }}" + f"#layoutDesc {{ color: {tokens.fg_muted.name()}; font-size: 12px; }}" + ) + self.add_button.refresh_theme(tokens) + for card in self.cards: + card.preview.tokens = tokens + card.preview.update() diff --git a/bec_widgets/widgets/containers/dock_area/chrome/gallery_common.py b/bec_widgets/widgets/containers/dock_area/chrome/gallery_common.py new file mode 100644 index 00000000..cb0babe4 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/gallery_common.py @@ -0,0 +1,209 @@ +"""State shared by the QWidget and QML versions of the Add widget gallery and the empty state.""" + +from __future__ import annotations + +from qtpy.QtCore import Property, QObject, Signal, Slot + +from bec_widgets.utils.quick.list_model import DictListModel +from bec_widgets.widgets.containers.dock_area.chrome.catalog import ( + PLACEMENTS, + STARTER_LAYOUTS, + WidgetEntry, + all_entries, +) + +ITEM_ROLES = ("widgetClass", "name", "description", "iconName", "category") +PLACEMENT_ROLES = ("key", "label", "hint", "available") +LAYOUT_ROLES = ("name", "description", "icon", "widgets") + + +class GalleryController(QObject): + """Search, highlight and placement of the Add widget gallery. + + Both views bind to this object, so they behave the same: typing filters, the arrow keys move + the highlight, Enter adds the highlighted widget, and the placement says where it goes. + + Args: + entries(tuple[WidgetEntry, ...] | None): Widgets to offer; defaults to the full catalog. + parent(QObject | None): Parent object. + """ + + widget_requested = Signal(str, str) + layout_requested = Signal(str) + query_changed = Signal() + placement_changed = Signal() + highlight_changed = Signal() + anchor_changed = Signal() + + def __init__(self, entries: tuple[WidgetEntry, ...] | None = None, parent=None): + super().__init__(parent) + self._entries = tuple(entries) if entries is not None else all_entries() + self._query = "" + self._placement = "beside" + self._anchor_title = "" + self._highlight = 0 + self.items = DictListModel(ITEM_ROLES, self) + self.placement_model = DictListModel(PLACEMENT_ROLES, self) + self.layouts = DictListModel(LAYOUT_ROLES, self) + self.layouts.set_items( + [ + { + "name": layout.name, + "description": layout.description, + "icon": layout.icon, + "widgets": len(layout.steps), + } + for layout in STARTER_LAYOUTS + ] + ) + self._refresh_items() + self._refresh_placements() + + # ------------------------------------------------------------------ entries + @property + def entries(self) -> tuple[WidgetEntry, ...]: + """All widgets the gallery offers.""" + return self._entries + + def filtered(self) -> list[WidgetEntry]: + """Entries matching the query, in catalog order.""" + return [entry for entry in self._entries if entry.matches(self._query)] + + def _refresh_items(self) -> None: + self.items.set_items( + [ + { + "widgetClass": entry.widget_class, + "name": entry.name, + "description": entry.description, + "iconName": entry.icon, + "category": entry.category, + } + for entry in self.filtered() + ] + ) + self._highlight = 0 if self.items.count else -1 + self.highlight_changed.emit() + + # ------------------------------------------------------------------ query + def _get_query(self) -> str: + return self._query + + @Slot(str) + def set_query(self, text: str) -> None: + """Filter the gallery by ``text``.""" + if text == self._query: + return + self._query = text + self._refresh_items() + self.query_changed.emit() + + query = Property(str, _get_query, set_query, notify=query_changed) + + # ------------------------------------------------------------------ placement + def _get_placement(self) -> str: + return self._placement if self._anchor_title or self._placement == "floating" else "column" + + @Slot(str) + def set_placement(self, key: str) -> None: + """Choose where the next widget goes (see :data:`~.catalog.PLACEMENTS`).""" + if key not in {p[0] for p in PLACEMENTS} or key == self._placement: + return + self._placement = key + self.placement_changed.emit() + self._refresh_placements() + + placement = Property(str, _get_placement, set_placement, notify=placement_changed) + + def _get_anchor_title(self) -> str: + return self._anchor_title + + def set_anchor_title(self, title: str) -> None: + """Name of the widget that Beside, Below and As tab refer to; empty when there is none.""" + title = title or "" + if title == self._anchor_title: + return + self._anchor_title = title + self.anchor_changed.emit() + self.placement_changed.emit() + self._refresh_placements() + + anchorTitle = Property(str, _get_anchor_title, notify=anchor_changed) + + def _refresh_placements(self) -> None: + relative = bool(self._anchor_title) + self.placement_model.set_items( + [ + { + "key": key, + "label": label, + "hint": hint, + "available": relative or key in ("column", "floating"), + } + for key, label, hint in PLACEMENTS + ] + ) + + @Slot(result=str) + def placement_summary(self) -> str: + """One line saying where the next widget goes, e.g. ``Beside Waveform 2``.""" + key = self._get_placement() + if key == "column": + return "As a new column at the right" + if key == "floating": + return "In its own window" + label = {"beside": "Beside", "below": "Below", "tab": "As a tab with"}[key] + return f"{label} {self._anchor_title}" + + placementSummary = Property( + str, lambda self: self.placement_summary(), notify=placement_changed + ) + + # ------------------------------------------------------------------ highlight + def _get_highlight(self) -> int: + return self._highlight + + @Slot(int) + def set_highlight(self, row: int) -> None: + """Highlight ``row`` of the filtered list.""" + row = max(-1, min(row, self.items.count - 1)) + if row == self._highlight: + return + self._highlight = row + self.highlight_changed.emit() + + highlight = Property(int, _get_highlight, set_highlight, notify=highlight_changed) + + @Slot(int) + def move_highlight(self, delta: int) -> None: + """Move the highlight by ``delta`` rows, wrapping around.""" + count = self.items.count + if count == 0: + return + self.set_highlight((max(self._highlight, 0) + delta) % count) + + @Slot() + @Slot(int) + def activate(self, row: int = -1) -> None: + """Add the widget at ``row`` (the highlighted one by default).""" + row = self._highlight if row < 0 else row + items = self.items.items + if not 0 <= row < len(items): + return + self.widget_requested.emit(items[row]["widgetClass"], self._get_placement()) + + @Slot(str) + def activate_layout(self, name: str) -> None: + """Build the starter layout called ``name``.""" + self.layout_requested.emit(name) + + @Slot() + def reset(self) -> None: + """Clear the search, e.g. when the gallery opens again.""" + self.set_query("") + self.set_highlight(0) + + # QML access to the models + itemModel = Property(QObject, lambda self: self.items, constant=True) + placementModel = Property(QObject, lambda self: self.placement_model, constant=True) + layoutModel = Property(QObject, lambda self: self.layouts, constant=True) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/qml/EmptyState.qml b/bec_widgets/widgets/containers/dock_area/chrome/qml/EmptyState.qml new file mode 100644 index 00000000..80f5a6e2 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/qml/EmptyState.qml @@ -0,0 +1,135 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// What an empty dock area shows: Add widget, three starter layouts and a docking hint. +Rectangle { + id: root + required property QtObject backend + signal addRequested() + + color: theme.bg + + readonly property var schematics: ({ + "Scanning": [[0, 0, 0.36, 1], [0.38, 0, 0.62, 0.58], [0.38, 0.62, 0.62, 0.38]], + "Alignment": [[0, 0, 0.58, 1], [0.6, 0, 0.4, 0.48], [0.6, 0.52, 0.4, 0.48]], + "Monitoring": [[0, 0, 0.4, 0.48], [0, 0.52, 0.4, 0.48], [0.42, 0, 0.58, 1]] + }) + + ColumnLayout { + anchors.centerIn: parent + anchors.verticalCenterOffset: -parent.height * 0.04 + width: Math.min(680, parent.width - 32) + spacing: 8 + + Rectangle { + Layout.alignment: Qt.AlignHCenter + width: 56; height: 56; radius: 28 + color: Qt.tint(theme.bg, Qt.alpha(theme.primary, 0.16)) + Icon { anchors.centerIn: parent; name: "dashboard_customize"; color: theme.primary; size: 30 } + } + Text { + Layout.alignment: Qt.AlignHCenter + text: "This workspace is empty" + color: theme.fg + font.pixelSize: 18 + font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: "Add the widgets you need, or start from a layout and change it afterwards." + color: theme.fgMuted + font.pixelSize: 13 + } + TextButton { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 8 + Layout.minimumWidth: 150 + text: "Add widget" + iconName: "add" + variant: "primary" + tip: "Open the widget gallery (Ctrl+Shift+A)" + onClicked: root.addRequested() + } + Text { + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: 18 + text: "Or start from a layout" + color: theme.fgMuted + font.pixelSize: 12 + font.weight: Font.DemiBold + } + RowLayout { + Layout.alignment: Qt.AlignHCenter + spacing: 12 + Repeater { + model: root.backend.layoutModel + delegate: AbstractButton { + id: card + required property string name + required property string description + implicitWidth: 196 + implicitHeight: content.implicitHeight + 24 + padding: 12 + hoverEnabled: true + focusPolicy: Qt.StrongFocus + Accessible.name: "Start from the " + name + " layout" + onClicked: root.backend.activate_layout(name) + background: Rectangle { + radius: 10 + color: theme.card + border.color: card.hovered || card.visualFocus ? theme.primary : theme.border + Behavior on border.color { ColorAnimation { duration: 90 } } + } + contentItem: ColumnLayout { + id: content + spacing: 4 + Item { + Layout.fillWidth: true + Layout.preferredHeight: 64 + Repeater { + model: root.schematics[card.name] || [] + delegate: Rectangle { + required property var modelData + x: modelData[0] * parent.width + y: modelData[1] * parent.height + width: modelData[2] * parent.width + height: modelData[3] * parent.height + radius: 4 + color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.14)) + border.color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.45)) + Rectangle { + x: 5; y: 5; height: 4; radius: 2 + width: Math.min(26, parent.width - 10) + color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.55)) + } + } + } + } + Text { + Layout.topMargin: 6 + text: card.name; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + text: card.description; color: theme.fgMuted; font.pixelSize: 12 + wrapMode: Text.WordWrap + } + } + } + } + } + Text { + Layout.fillWidth: true + Layout.topMargin: 14 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: "Tip: drag a tab onto the edge of another widget to split the view, or onto its centre to stack them as tabs." + color: theme.fgSubtle + font.pixelSize: 12 + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/chrome/qml/WidgetGallery.qml b/bec_widgets/widgets/containers/dock_area/chrome/qml/WidgetGallery.qml new file mode 100644 index 00000000..46f74763 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/qml/WidgetGallery.qml @@ -0,0 +1,171 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts +import BecUi + +// The Add widget gallery: search, placement, and the widgets grouped by category. +Rectangle { + id: root + required property QtObject backend + signal closeRequested() + + color: theme.card + radius: 10 + border.color: theme.border + border.width: 1 + + function focusSearch() { search.forceActiveFocus(); search.selectAll() } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + anchors.bottomMargin: 10 + spacing: 10 + + InputField { + id: search + Layout.fillWidth: true + placeholderText: "Search widgets, e.g. plot, motor, queue" + text: root.backend.query + onTextEdited: root.backend.set_query(text) + Keys.onDownPressed: root.backend.move_highlight(1) + Keys.onUpPressed: root.backend.move_highlight(-1) + Keys.onReturnPressed: root.backend.activate() + Keys.onEnterPressed: root.backend.activate() + Keys.onEscapePressed: root.closeRequested() + } + + RowLayout { + spacing: 4 + Text { text: "Place"; color: theme.fgMuted; font.pixelSize: 12; rightPadding: 4 } + Repeater { + model: root.backend.placementModel + delegate: AbstractButton { + id: chip + required property string key + required property string label + required property string hint + required property bool available + enabled: available + readonly property bool selected: root.backend.placement === key + implicitHeight: 26 + implicitWidth: chipText.implicitWidth + 18 + hoverEnabled: true + opacity: enabled ? 1 : 0.45 + onClicked: root.backend.set_placement(key) + ToolTip.visible: hovered + ToolTip.delay: 500 + ToolTip.text: hint + background: Rectangle { + radius: 6 + color: chip.selected ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.2)) + : chip.hovered ? theme.hover : "transparent" + border.color: chip.selected ? theme.primary : theme.border + } + contentItem: Text { + id: chipText + text: chip.label + color: theme.fg + font.pixelSize: 12 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + } + } + Item { Layout.fillWidth: true } + } + + Text { + text: "Goes: " + root.backend.placementSummary + color: theme.fgMuted + font.pixelSize: 12 + } + + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.backend.itemModel + currentIndex: root.backend.highlight + highlightFollowsCurrentItem: false + boundsBehavior: Flickable.StopAtBounds + onCurrentIndexChanged: positionViewAtIndex(currentIndex, ListView.Contain) + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + + section.property: "category" + section.delegate: Text { + required property string section + width: ListView.view.width + height: 30 + leftPadding: 12 + topPadding: 10 + text: section.toUpperCase() + color: theme.fgSubtle + font.pixelSize: 11 + font.weight: Font.DemiBold + font.letterSpacing: 0.6 + } + + delegate: ItemDelegate { + id: row + required property int index + required property string name + required property string description + required property string iconName + required property string widgetClass + readonly property bool isCurrent: ListView.isCurrentItem + width: ListView.view.width + height: 50 + hoverEnabled: true + onHoveredChanged: if (hovered) root.backend.set_highlight(index) + onClicked: root.backend.activate(index) + ToolTip.visible: hovered + ToolTip.delay: 800 + ToolTip.text: description + "\nClass: " + widgetClass + background: Rectangle { + x: 4; width: parent.width - 8; height: parent.height - 2; y: 1 + radius: 7 + color: row.isCurrent ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.14)) + : row.hovered ? theme.hover : "transparent" + border.width: row.isCurrent ? 1 : 0 + border.color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.5)) + } + contentItem: RowLayout { + spacing: 12 + Rectangle { + Layout.leftMargin: 8 + width: 32; height: 32; radius: 7 + color: Qt.tint(theme.card, Qt.alpha(theme.primary, 0.18)) + Icon { anchors.centerIn: parent; name: row.iconName; color: theme.primary; size: 18 } + } + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Text { text: row.name; color: theme.fg; font.pixelSize: 13; font.weight: Font.DemiBold } + Text { + Layout.fillWidth: true + text: row.description; color: theme.fgMuted; font.pixelSize: 12 + elide: Text.ElideRight + } + } + } + } + } + + Text { + visible: list.count === 0 + Layout.fillWidth: true + horizontalAlignment: Text.AlignHCenter + text: "No widget matches. Try another word." + color: theme.fgMuted + font.pixelSize: 12 + } + + Text { + text: "↑ ↓ choose · Enter add · Esc close" + color: theme.fgSubtle + font.pixelSize: 11 + } + } +} diff --git a/bec_widgets/widgets/containers/dock_area/chrome/tab_rename.py b/bec_widgets/widgets/containers/dock_area/chrome/tab_rename.py new file mode 100644 index 00000000..e1db14fb --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/tab_rename.py @@ -0,0 +1,103 @@ +"""Rename a dock in place by double-clicking its tab.""" + +from __future__ import annotations + +from typing import Callable + +from qtpy.QtCore import QEvent, QObject, Qt +from qtpy.QtWidgets import QLineEdit, QWidget +from shiboken6 import isValid + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.widgets.containers.qt_ads import CDockWidget + + +class TabRenameEditor(QLineEdit): + """Line edit laid over a dock tab. Enter or focus loss commits, Escape cancels.""" + + def __init__(self, dock: CDockWidget, on_commit: Callable[[CDockWidget, str], None]): + tab = dock.tabWidget() + super().__init__(dock.windowTitle(), tab) + self.setObjectName("dockTabRenameEditor") + self._dock = dock + self._on_commit = on_commit + self._done = False + tokens = ThemeTokens() + self.setStyleSheet( + f"#dockTabRenameEditor {{ background: {tokens.field.name()}; color: {tokens.fg.name()};" + f" border: 2px solid {tokens.primary.name()}; border-radius: 4px; padding: 0 4px; }}" + ) + self.setGeometry(tab.rect().adjusted(2, 2, -2, -2)) + self.setMinimumWidth(120) + self.resize(max(self.width(), 140), self.height()) + self.selectAll() + self.editingFinished.connect(self.commit) + + def keyPressEvent(self, event): # pylint: disable=invalid-name + """Escape cancels the rename.""" + if event.key() == Qt.Key.Key_Escape: + self._finish() + return + super().keyPressEvent(event) + + def commit(self) -> None: + """Apply the new title unless it is empty.""" + if self._done: + return + text = self.text().strip() + if text and isValid(self._dock) and text != self._dock.windowTitle(): + self._on_commit(self._dock, text) + self._finish() + + def _finish(self) -> None: + self._done = True + self.hide() + self.deleteLater() + + +class TabRenameFilter(QObject): + """Event filter that opens a :class:`TabRenameEditor` on double-click of a dock tab. + + Args: + on_commit(Callable[[CDockWidget, str], None]): Called with the dock and its new title. + enabled(Callable[[], bool]): Returns False while renaming is not allowed (locked layout). + parent(QObject | None): Parent object. + """ + + def __init__( + self, + on_commit: Callable[[CDockWidget, str], None], + enabled: Callable[[], bool] = lambda: True, + parent: QObject | None = None, + ): + super().__init__(parent) + self._on_commit = on_commit + self._enabled = enabled + self._tabs: dict[int, CDockWidget] = {} + + def watch(self, dock: CDockWidget) -> None: + """Start listening for double-clicks on the tab of ``dock``.""" + tab = dock.tabWidget() + if tab is None: + return + self._tabs[id(tab)] = dock + tab.installEventFilter(self) + tab.setToolTip(f"{dock.windowTitle()}\nDouble-click to rename · ID: {dock.objectName()}") + + def start_rename(self, dock: CDockWidget) -> QLineEdit | None: + """Open the inline editor on ``dock``'s tab and return it.""" + if not isValid(dock) or not self._enabled(): + return None + editor = TabRenameEditor(dock, self._on_commit) + editor.show() + editor.setFocus(Qt.FocusReason.MouseFocusReason) + return editor + + def eventFilter(self, watched: QWidget, event: QEvent): # pylint: disable=invalid-name + """Open the editor on a double-click of a watched tab.""" + if event.type() == QEvent.Type.MouseButtonDblClick: + dock = self._tabs.get(id(watched)) + if dock is not None and isValid(dock): + self.start_rename(dock) + return True + return super().eventFilter(watched, event) diff --git a/bec_widgets/widgets/containers/dock_area/chrome/undo_close.py b/bec_widgets/widgets/containers/dock_area/chrome/undo_close.py new file mode 100644 index 00000000..edf9ae66 --- /dev/null +++ b/bec_widgets/widgets/containers/dock_area/chrome/undo_close.py @@ -0,0 +1,147 @@ +"""Close a dock with Undo instead of deleting it at once. + +A closed dock is hidden and kept for :data:`UNDO_SECONDS`. A small bar at the bottom of the dock +area says what was closed and offers Undo. When the time runs out, or before anything that saves +or rebuilds the layout, the hidden docks are deleted for real. +""" + +from __future__ import annotations + +from typing import Callable + +from qtpy.QtCore import QEvent, QObject, Qt, QTimer, Signal +from qtpy.QtWidgets import QHBoxLayout, QLabel, QWidget +from shiboken6 import isValid + +from bec_widgets.utils.quick.host import ThemeTokens +from bec_widgets.utils.ux_kit import IconButton, TextButton +from bec_widgets.widgets.containers.qt_ads import CDockWidget + +UNDO_SECONDS = 8 + + +class PendingCloses(QObject): + """Bookkeeping of docks that were closed but can still be restored. + + Args: + finalize(Callable[[CDockWidget], None]): Deletes a dock for real. + parent(QObject | None): Parent object. + """ + + changed = Signal() + + def __init__(self, finalize: Callable[[CDockWidget], None], parent: QObject | None = None): + super().__init__(parent) + self._finalize = finalize + self._docks: list[CDockWidget] = [] + self.timer = QTimer(self) + self.timer.setSingleShot(True) + self.timer.setInterval(UNDO_SECONDS * 1000) + self.timer.timeout.connect(self.flush) + + def __contains__(self, dock: object) -> bool: + return any(d is dock for d in self._docks) + + def __len__(self) -> int: + return len(self._docks) + + @property + def last(self) -> CDockWidget | None: + """The most recently closed dock that can still be restored.""" + self._docks = [d for d in self._docks if isValid(d)] + return self._docks[-1] if self._docks else None + + def close(self, dock: CDockWidget) -> None: + """Hide ``dock`` and keep it restorable for a few seconds.""" + if dock in self: + return + dock.toggleView(False) + self._docks.append(dock) + self.timer.start() + self.changed.emit() + + def undo(self) -> CDockWidget | None: + """Show the most recently closed dock again and return it.""" + dock = self.last + if dock is None: + return None + self._docks.remove(dock) + dock.toggleView(True) + dock.setAsCurrentTab() + if self._docks: + self.timer.start() + else: + self.timer.stop() + self.changed.emit() + return dock + + def flush(self) -> None: + """Delete every pending dock now.""" + self.timer.stop() + docks, self._docks = self._docks, [] + for dock in docks: + if isValid(dock): + self._finalize(dock) + if docks: + self.changed.emit() + + +class UndoBar(QWidget): + """Floating bar shown at the bottom of the dock area after a widget was closed.""" + + undo_requested = Signal() + dismissed = Signal() + + def __init__(self, parent: QWidget): + super().__init__(parent) + self.setObjectName("dockUndoBar") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 6, 6, 6) + layout.setSpacing(8) + self.label = QLabel(self) + self.undo_button = TextButton("Undo", "ghost", "undo", self) + self.undo_button.setToolTip("Bring the widget back (Ctrl+Z)") + self.close_button = IconButton("close", "Dismiss", self, size=16) + layout.addWidget(self.label, 1) + layout.addWidget(self.undo_button) + layout.addWidget(self.close_button) + self.undo_button.clicked.connect(self.undo_requested) + self.close_button.clicked.connect(self.dismissed) + parent.installEventFilter(self) + self.refresh_theme() + self.hide() + + def refresh_theme(self) -> None: + """Apply the current theme colours.""" + tokens = ThemeTokens() + self.setStyleSheet( + f"#dockUndoBar {{ background: {tokens.card.name()};" + f" border: 1px solid {tokens.border.name()}; border-radius: 8px; }}" + f"#dockUndoBar QLabel {{ color: {tokens.fg.name()}; font-size: 13px; }}" + ) + self.undo_button.refresh_theme(tokens) + self.close_button.refresh_theme(tokens) + + def show_message(self, text: str) -> None: + """Show ``text`` with the Undo button and place the bar.""" + self.label.setText(text) + self.adjustSize() + self._place() + self.show() + self.raise_() + + def _place(self) -> None: + parent = self.parentWidget() + if parent is None: + return + width = min(max(self.sizeHint().width(), 320), max(parent.width() - 32, 200)) + self.resize(width, self.sizeHint().height()) + self.move((parent.width() - width) // 2, parent.height() - self.height() - 18) + + def eventFilter(self, watched, event): # pylint: disable=invalid-name + """Keep the bar at the bottom centre when the dock area is resized.""" + if watched is self.parentWidget() and event.type() == QEvent.Type.Resize: + if self.isVisible(): + self._place() + return super().eventFilter(watched, event) diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 8d7e5f54..1455ce45 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -6,14 +6,17 @@ from typing import Literal, Mapping, Sequence import slugify from bec_lib import bec_logger -from qtpy.QtCore import Signal +from bec_qthemes import material_icon +from qtpy.QtCore import QSize, Qt, Signal from qtpy.QtGui import QPixmap from qtpy.QtWidgets import ( QApplication, QDialog, QInputDialog, + QMenu, QMessageBox, QSizePolicy, + QToolButton, QVBoxLayout, QWidget, ) @@ -28,6 +31,7 @@ from bec_widgets.utils.bec_plugin_helper import ( from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.name_utils import pascal_to_title from bec_widgets.utils.plugin_utils import get_rpc_widget_registry +from bec_widgets.utils.quick.host import ThemeTokens from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_widget_handler import widget_handler from bec_widgets.utils.toolbars.actions import ( @@ -38,8 +42,10 @@ from bec_widgets.utils.toolbars.actions import ( ) from bec_widgets.utils.toolbars.bundles import ToolbarBundle from bec_widgets.utils.toolbars.toolbar import ModularToolBar +from bec_widgets.utils.ux_kit import TextButton from bec_widgets.utils.widget_state_manager import WidgetStateManager from bec_widgets.widgets.containers.dock_area.basic_dock_area import DockAreaWidget +from bec_widgets.widgets.containers.dock_area.chrome import DockChrome from bec_widgets.widgets.containers.dock_area.profile_utils import ( SETTINGS_KEYS, baseline_profile_candidates, @@ -159,6 +165,14 @@ class BECDockArea(DockAreaWidget): self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) self.dark_mode_button.setVisible(enable_profile_management) + + # Improved chrome: human titles, close with undo, Add widget gallery, empty state + self._chrome: DockChrome | None = None + self.add_widget_button: TextButton | None = None + self.lock_button: QToolButton | None = None + self.screenshot_button: QToolButton | None = None + if self._chrome_mode != "legacy": + self._chrome = DockChrome(self, mode=self._chrome_mode) self._setup_toolbar() self._hook_toolbar() @@ -270,6 +284,41 @@ class BECDockArea(DockAreaWidget): prefs["show_settings_action"] = True dock._dock_preferences = prefs super()._customize_dock(dock, widget) + chrome = getattr(self, "_chrome", None) + if chrome is not None: + chrome.init_dock(dock, widget) + + def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None: + """Close a dock from its tab. With the improved chrome the dock can be restored.""" + chrome = getattr(self, "_chrome", None) + if chrome is None: + super()._default_close_handler(dock, widget) + return + chrome.close_dock(dock) + + def dock_map(self) -> dict[str, CDockWidget]: + """Return the dock widgets map as dictionary with names as keys. + + Docks that were closed but can still be restored with Undo are not included. + """ + docks = super().dock_map() + chrome = getattr(self, "_chrome", None) + if chrome is None or len(chrome.pending) == 0: + return docks + return {name: dock for name, dock in docks.items() if dock not in chrome.pending} + + @SafeSlot() + def delete_all(self): + """Delete all docks and their associated widgets, including ones waiting for Undo.""" + chrome = getattr(self, "_chrome", None) + if chrome is not None: + chrome.flush() + super().delete_all() + + def open_widget_gallery(self) -> None: + """Open the Add widget gallery (Ctrl+Shift+A).""" + if self._chrome is not None: + self._chrome.open_gallery(getattr(self, "add_widget_button", None)) @SafeSlot(popup_error=True) def new( @@ -514,8 +563,14 @@ class BECDockArea(DockAreaWidget): self.toolbar.components.add_safe("dark_mode", dark_mode_action) bda = ToolbarBundle("dock_actions", self.toolbar.components) - bda.add_action("attach_all") - bda.add_action("screenshot") + if self._chrome is not None: + self._setup_modern_toolbar_actions() + bda.add_action("lock_layout") + bda.add_action("attach_all") + bda.add_action("screenshot_menu") + else: + bda.add_action("attach_all") + bda.add_action("screenshot") bda.add_action("dark_mode") self.toolbar.add_bundle(bda) @@ -530,6 +585,98 @@ class BECDockArea(DockAreaWidget): self._apply_toolbar_layout() + def _setup_modern_toolbar_actions(self) -> None: + """Toolbar controls of the improved chrome: Add widget, Lock layout, Screenshot menu.""" + self.add_widget_button = TextButton("Add widget", "primary", "add", parent=self) + self.add_widget_button.setObjectName("addWidgetButton") + self.add_widget_button.setToolTip("Add a widget to this workspace (Ctrl+Shift+A)") + self.add_widget_button.clicked.connect(self.open_widget_gallery) + self.toolbar.components.add_safe( + "add_widget", + WidgetAction(widget=self.add_widget_button, adjust_size=False, parent=self), + ) + add_bundle = ToolbarBundle("add_widget", self.toolbar.components) + add_bundle.add_action("add_widget") + self.toolbar.add_bundle(add_bundle) + + self.lock_button = QToolButton(self) + self.lock_button.setObjectName("lockLayoutButton") + self.lock_button.setCheckable(True) + self.lock_button.setAutoRaise(True) + self.lock_button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.lock_button.setIconSize(QSize(18, 18)) + self.lock_button.toggled.connect(self._on_lock_button_toggled) + self.toolbar.components.add_safe( + "lock_layout", WidgetAction(widget=self.lock_button, adjust_size=False, parent=self) + ) + + self.screenshot_button = QToolButton(self) + self.screenshot_button.setObjectName("screenshotMenuButton") + self.screenshot_button.setAutoRaise(True) + self.screenshot_button.setToolTip("Screenshot of this workspace") + self.screenshot_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.screenshot_button.setIconSize(QSize(18, 18)) + menu = QMenu(self.screenshot_button) + menu.addAction("Save image…", self.screenshot) + menu.addAction("Copy image", self.copy_screenshot) + menu.addSeparator() + menu.addAction("Send to SciLog…", self.screenshot_to_scilog) + self.screenshot_button.setMenu(menu) + self.toolbar.components.add_safe( + "screenshot_menu", + WidgetAction(widget=self.screenshot_button, adjust_size=False, parent=self), + ) + self.toolbar.components.get_action("attach_all").action.setToolTip( + "Dock all floating windows back into this workspace" + ) + self._refresh_modern_toolbar() + + def _refresh_modern_toolbar(self) -> None: + """Update icons, labels and colours of the improved toolbar controls.""" + if self._chrome is None: + return + tokens = ThemeTokens() + locked = self._locked + self.add_widget_button.refresh_theme(tokens) + self.add_widget_button.setEnabled(not locked) + self.lock_button.blockSignals(True) + self.lock_button.setChecked(locked) + self.lock_button.blockSignals(False) + self.lock_button.setText("Layout locked" if locked else "Lock layout") + self.lock_button.setToolTip( + "Unlock to move, add or close widgets again" + if locked + else "Lock the layout so widgets cannot be moved or closed by accident. " + "The widgets keep working." + ) + color = tokens.primary if locked else tokens.fg_muted + self.lock_button.setIcon( + material_icon("lock" if locked else "lock_open", size=(36, 36), color=color) + ) + self.screenshot_button.setIcon( + material_icon("photo_camera", size=(36, 36), color=tokens.fg_muted) + ) + + def _on_lock_button_toggled(self, checked: bool) -> None: + self._set_editable(not checked) + + @SafeSlot() + def copy_screenshot(self) -> None: + """Copy an image of the dock area to the clipboard.""" + QApplication.clipboard().setPixmap(self.grab()) + + @SafeSlot(str) + def apply_theme(self, theme: str): + """Re-apply the chrome colours after a theme change. + + Args: + theme(str): Name of the new theme. + """ + super().apply_theme(theme) + if self._chrome is not None: + self._chrome.refresh_theme() + self._refresh_modern_toolbar() + def _hook_toolbar(self): def _connect_menu(menu_key: str): menu = self.toolbar.components.get_action(menu_key) @@ -622,6 +769,8 @@ class BECDockArea(DockAreaWidget): self.toolbar.components.get_action("save_workspace").action.setVisible(not value) for dock in self.dock_list(): dock.setting_action.setVisible(not value) + if getattr(self, "_chrome", None) is not None and self.lock_button is not None: + self._refresh_modern_toolbar() def _last_profile_instance_id(self) -> str | None: """ @@ -673,6 +822,8 @@ class BECDockArea(DockAreaWidget): settings(QSettings): The settings object to write to. save_preview(bool): Whether to save a screenshot preview. """ + if self._chrome is not None: + self._chrome.flush() self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) write_manifest(settings, self.dock_list()) @@ -955,7 +1106,7 @@ class BECDockArea(DockAreaWidget): "absolute": item.get("floating_absolute"), "screen_name": item.get("floating_screen"), } - self._make_dock( + dock = self._make_dock( w, closable=item["closable"], floatable=item["floatable"], @@ -964,6 +1115,11 @@ class BECDockArea(DockAreaWidget): floating_state=floating_state, area=QtAds.DockWidgetArea.RightDockWidgetArea, ) + if self._chrome is not None and item.get("title"): + self._chrome.rename_dock(dock, item["title"]) + dock._custom_title = bool( # pylint: disable=protected-access + item.get("custom_title") + ) self.load_from_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.load_state(settings=settings) @@ -1184,6 +1340,9 @@ class BECDockArea(DockAreaWidget): mode_key = getattr(self, "_mode", "creator") if mode_key == "user": bundles = ["spacer_bundle", "workspace", "dock_actions"] + elif mode_key == "creator" and getattr(self, "_chrome", None) is not None: + # One Add widget gallery replaces the three Add menus + bundles = ["add_widget", "spacer_bundle", "workspace", "dock_actions"] elif mode_key == "creator": bundles = ["menu_plots", "menu_devices", "menu_utils"] if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}): @@ -1274,6 +1433,8 @@ class BECDockArea(DockAreaWidget): if self.manage_dialog is not None: self.manage_dialog.reject() self.manage_dialog = None + if self._chrome is not None: + self._chrome.cleanup() self.delete_all() self.dark_mode_button.close() self.dark_mode_button.deleteLater() diff --git a/bec_widgets/widgets/containers/dock_area/profile_utils.py b/bec_widgets/widgets/containers/dock_area/profile_utils.py index 53564b6f..a73928c2 100644 --- a/bec_widgets/widgets/containers/dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/dock_area/profile_utils.py @@ -749,6 +749,8 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None: settings.setValue("closable", getattr(dock, "_default_closable", True)) settings.setValue("floatable", getattr(dock, "_default_floatable", True)) settings.setValue("movable", getattr(dock, "_default_movable", True)) + settings.setValue("title", dock.windowTitle()) + settings.setValue("custom_title", bool(getattr(dock, "_custom_title", False))) is_floating = bool(dock.isFloating()) settings.setValue("floating", is_floating) if is_floating: @@ -815,6 +817,8 @@ def read_manifest(settings: QSettings) -> list[dict]: "closable": settings.value("closable", type=bool), "floatable": settings.value("floatable", type=bool), "movable": settings.value("movable", type=bool), + "title": settings.value("title", "") or "", + "custom_title": settings.value("custom_title", False, type=bool), "floating": floating, "floating_screen": settings.value("floating_screen", ""), "floating_relative": rel, diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index fa91bbc1..43372593 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -46,6 +46,13 @@ from bec_widgets.widgets.containers.dock_area.settings.workspace_manager import from bec_widgets.widgets.plots.waveform.waveform import Waveform +@pytest.fixture +def legacy_chrome(monkeypatch): + """Run a test against the dock area chrome as it was before the UX rework.""" + monkeypatch.setenv("BEC_DOCK_CHROME", "legacy") + yield + + @pytest.fixture def advanced_dock_area(qtbot, mocked_client): """Create an AdvancedDockArea instance for testing.""" @@ -990,6 +997,7 @@ class TestToolbarFunctionality: floating_dock = advanced_dock_area.dock_map()["FloatingWaveform"] assert floating_dock.isFloating() + @pytest.mark.usefixtures("legacy_chrome") def test_screenshot_action(self, advanced_dock_area, tmpdir): """Test screenshot toolbar action.""" # Create a test screenshot file path in tmpdir @@ -1017,6 +1025,7 @@ class TestToolbarFunctionality: # Verify save was called with the filename mock_screenshot.save.assert_called_once_with(str(screenshot_path)) + @pytest.mark.usefixtures("legacy_chrome") def test_screenshot_button_has_scilog_dropdown(self, advanced_dock_area): """Test screenshot toolbar button exposes a SciLog dropdown option.""" action = advanced_dock_area.toolbar.components.get_action("screenshot") @@ -1030,6 +1039,7 @@ class TestToolbarFunctionality: "Send Screenshot to SciLog", ] + @pytest.mark.usefixtures("legacy_chrome") def test_screenshot_to_scilog_action(self, advanced_dock_area): """Test sending a screenshot through the BEC SciLog messaging service.""" mock_message = mock.MagicMock() @@ -1128,6 +1138,7 @@ class TestDockSettingsDialog: assert dialog.isModal() assert hasattr(dialog, "prop_editor") + @pytest.mark.usefixtures("legacy_chrome") def test_open_dock_settings_dialog(self, advanced_dock_area, qtbot): """Test opening dock settings dialog.""" from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import ( @@ -2432,6 +2443,7 @@ class TestToolbarModeBundles: assert "flat_plots" not in shown_bundles assert "flat_devices" not in shown_bundles + @pytest.mark.usefixtures("legacy_chrome") def test_developer_mode_toolbar_visibility(self, advanced_dock_area): """Test toolbar bundle visibility in developer mode.""" advanced_dock_area.mode = "creator" diff --git a/tests/unit_tests/test_dock_chrome.py b/tests/unit_tests/test_dock_chrome.py new file mode 100644 index 00000000..5d681184 --- /dev/null +++ b/tests/unit_tests/test_dock_chrome.py @@ -0,0 +1,247 @@ +# pylint: disable=missing-function-docstring, missing-module-docstring, redefined-outer-name, protected-access + +import pytest +from qtpy.QtCore import Qt + +import bec_widgets.widgets.containers.qt_ads as QtAds +from bec_widgets.widgets.containers.dock_area.chrome import DockChrome +from bec_widgets.widgets.containers.dock_area.chrome.catalog import ( + CORE_WIDGETS, + STARTER_LAYOUTS, + display_name, + unique_title, +) +from bec_widgets.widgets.containers.dock_area.chrome.gallery_common import GalleryController +from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea + + +@pytest.fixture(autouse=True) +def isolate_profile_storage(tmp_path, monkeypatch): + monkeypatch.setenv("BECWIDGETS_PROFILE_DIR", str(tmp_path / "profiles")) + + +@pytest.fixture(params=["qwidget"]) +def dock_area(request, qtbot, mocked_client, monkeypatch): + monkeypatch.setenv("BEC_DOCK_CHROME", request.param) + widget = BECDockArea(client=mocked_client, startup_profile=None) + qtbot.addWidget(widget) + widget.resize(1000, 700) + widget.show() + qtbot.waitExposed(widget) + yield widget + + +def _dock_of(area, widget): + return area._chrome.dock_of(widget) + + +# ---------------------------------------------------------------------- catalog + + +def test_catalog_names_and_titles(): + assert display_name("PositionerBox") == "Motor" + assert display_name("BECQueue") == "Scan Queue" + assert display_name("SomePluginWidget") == "Some Plugin Widget" + assert unique_title("Waveform", set()) == "Waveform" + assert unique_title("Waveform", {"Waveform"}) == "Waveform 2" + assert unique_title("Waveform", {"Waveform", "Waveform 2"}) == "Waveform 3" + assert len({entry.widget_class for entry in CORE_WIDGETS}) == len(CORE_WIDGETS) + + +def test_gallery_controller_filter_highlight_and_activate(qtbot): + controller = GalleryController(entries=CORE_WIDGETS) + assert controller.items.count == len(CORE_WIDGETS) + controller.set_query("motor") + names = [item["name"] for item in controller.items.items] + assert "Motor" in names and "2D Motor" in names and "Scan Queue" not in names + controller.set_query("device box") # today's menu label still finds it + assert [item["name"] for item in controller.items.items] == ["Motor", "2D Motor"] + + controller.set_query("") + controller.move_highlight(-1) + assert controller.highlight == controller.items.count - 1 + controller.move_highlight(1) + assert controller.highlight == 0 + + with qtbot.waitSignal(controller.widget_requested) as blocker: + controller.activate() + # no anchor yet: relative placements fall back to a new column + assert blocker.args == ["Waveform", "column"] + + controller.set_anchor_title("Waveform") + controller.set_placement("tab") + assert controller.placement_summary() == "As a tab with Waveform" + with qtbot.waitSignal(controller.widget_requested) as blocker: + controller.activate(1) + assert blocker.args == ["ScatterWaveform", "tab"] + + +# ---------------------------------------------------------------------- behaviour + + +def test_ads_flags_close_tabs_not_groups(dock_area): + flag = QtAds.CDockManager.eConfigFlag + assert not QtAds.CDockManager.testConfigFlag(flag.DockAreaHasCloseButton) + assert not QtAds.CDockManager.testConfigFlag(flag.DoubleClickUndocksWidget) + assert QtAds.CDockManager.testConfigFlag(flag.AllTabsHaveCloseButton) + + +def test_human_titles_numbered(dock_area): + first = dock_area.new("Waveform") + second = dock_area.new("Waveform") + motor = dock_area.new("PositionerBox") + assert _dock_of(dock_area, first).windowTitle() == "Waveform" + assert _dock_of(dock_area, second).windowTitle() == "Waveform 2" + assert _dock_of(dock_area, motor).windowTitle() == "Motor" + # object names (the API) are unchanged + assert motor.objectName().startswith("PositionerBox") + + +def test_close_hides_and_undo_restores(dock_area, qtbot): + widget = dock_area.new("Waveform") + dock = _dock_of(dock_area, widget) + name = dock.objectName() + + dock.closeRequested.emit() + qtbot.wait(10) + assert dock.isClosed() + assert name not in dock_area.dock_map() + assert dock_area._chrome.undo_bar.isVisible() + assert "Waveform" in dock_area._chrome.undo_bar.label.text() + + dock_area._chrome.undo_close() + assert not dock.isClosed() + assert name in dock_area.dock_map() + assert not dock_area._chrome.undo_bar.isVisible() + + +def test_close_is_final_after_flush(dock_area, qtbot): + widget = dock_area.new("Waveform") + dock = _dock_of(dock_area, widget) + dock.closeRequested.emit() + dock_area._chrome.pending.flush() + qtbot.waitUntil(lambda: dock_area.dock_manager.dockWidgetsMap() == {}) + + +def test_closing_several_at_once_undoes_together(dock_area, qtbot): + a = _dock_of(dock_area, dock_area.new("Waveform")) + b = _dock_of(dock_area, dock_area.new("Image")) + a.closeRequested.emit() + b.closeRequested.emit() + assert "2 widgets" in dock_area._chrome.undo_bar.label.text() + dock_area._chrome.undo_close() + assert not a.isClosed() and not b.isClosed() + + +def test_delete_all_flushes_pending(dock_area, qtbot): + dock = _dock_of(dock_area, dock_area.new("Waveform")) + dock.closeRequested.emit() + dock_area.delete_all() + assert len(dock_area._chrome.pending) == 0 + qtbot.waitUntil(lambda: dock_area.dock_manager.dockWidgetsMap() == {}) + + +def test_rename_is_saved_with_the_profile(dock_area, qtbot): + widget = dock_area.new("Waveform") + dock = _dock_of(dock_area, widget) + dock_area._chrome.rename_dock(dock, "Beam profile") + assert dock.windowTitle() == "Beam profile" + + dock_area.save_profile("titles", show_dialog=False) + dock_area.delete_all() + dock_area.load_profile("titles") + restored = list(dock_area.dock_map().values()) + assert [d.windowTitle() for d in restored] == ["Beam profile"] + assert restored[0]._custom_title + + +def test_inline_rename_editor(dock_area, qtbot): + dock = _dock_of(dock_area, dock_area.new("Waveform")) + editor = dock_area._chrome.renamer.start_rename(dock) + assert editor is not None + editor.setText(" Diode scan ") + editor.commit() + assert dock.windowTitle() == "Diode scan" + + +def test_rename_blocked_when_locked(dock_area): + dock = _dock_of(dock_area, dock_area.new("Waveform")) + dock_area.lock_button.setChecked(True) + assert dock_area.workspace_is_locked + assert not dock_area.add_widget_button.isEnabled() + assert dock_area.lock_button.text() == "Layout locked" + assert dock_area._chrome.renamer.start_rename(dock) is None + dock_area.lock_button.setChecked(False) + assert not dock_area.workspace_is_locked + + +@pytest.mark.parametrize("placement", ["beside", "below", "tab", "floating", "column"]) +def test_add_widget_placement(dock_area, placement): + anchor = _dock_of(dock_area, dock_area.new("Waveform")) + dock_area.dock_manager.setDockWidgetFocused(anchor) + widget = dock_area._chrome.add_widget("Image", placement) + dock = _dock_of(dock_area, widget) + assert dock is not None + same_area = dock.dockAreaWidget() is anchor.dockAreaWidget() + if placement == "tab": + assert same_area + elif placement == "floating": + assert dock.isFloating() + else: + assert not same_area and not dock.isFloating() + + +def test_empty_state_and_starter_layout(dock_area, qtbot): + chrome: DockChrome = dock_area._chrome + qtbot.waitUntil(chrome.empty_state.isVisible) + chrome.controller.activate_layout("Monitoring") + layout = next(item for item in STARTER_LAYOUTS if item.name == "Monitoring") + assert len(dock_area.dock_list()) == len(layout.steps) + assert not chrome.empty_state.isVisible() + dock_area.delete_all() + qtbot.waitUntil(chrome.empty_state.isVisible) + + +def test_toolbar_has_one_add_button(dock_area): + shown = dock_area.toolbar.shown_bundles + assert shown[0] == "add_widget" + assert "menu_plots" not in shown and "menu_utils" not in shown + + +def test_qwidget_gallery_keyboard(dock_area, qtbot): + dock_area.open_widget_gallery() + gallery = dock_area._chrome.gallery + qtbot.waitUntil(gallery.isVisible) + gallery.search.setText("queue") + qtbot.keyClick(gallery.search, Qt.Key.Key_Return) + assert [d.windowTitle() for d in dock_area.dock_list()] == ["Scan Queue"] + assert not gallery.isVisible() + + +def test_qml_chrome_loads(qtbot, mocked_client, monkeypatch): + monkeypatch.setenv("BEC_DOCK_CHROME", "qml") + area = BECDockArea(client=mocked_client, startup_profile=None) + qtbot.addWidget(area) + area.show() + qtbot.waitExposed(area) + chrome = area._chrome + assert chrome.empty_state.view.rootObject() is not None + assert not chrome.empty_state.view.errors() + area.open_widget_gallery() + assert chrome.gallery.view.rootObject() is not None + assert not chrome.gallery.view.errors() + chrome.controller.set_query("heat") + chrome.controller.activate() + assert [d.windowTitle() for d in area.dock_list()] == ["Heatmap"] + + +def test_legacy_mode_keeps_old_chrome(qtbot, mocked_client, monkeypatch): + monkeypatch.setenv("BEC_DOCK_CHROME", "legacy") + area = BECDockArea(client=mocked_client, startup_profile=None) + qtbot.addWidget(area) + assert area._chrome is None + assert "menu_plots" in area.toolbar.shown_bundles + flag = QtAds.CDockManager.eConfigFlag + assert QtAds.CDockManager.testConfigFlag(flag.DockAreaHasCloseButton) + widget = area.new("Waveform") + assert area.dock_list()[0].windowTitle() == widget.objectName()