feat(dock_area): human titles, close with undo, Add widget gallery and empty state (QWidget and QML)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpeYDYWxwjcsqJt2BwhDNR
This commit is contained in:
Claude committed 2026-10-09 22:26:29 +00:00
1 parent 703513b175
commit 143021e1ea
30 files changed
+3652 -7

No files matched your search

+2 -2
View File
@@ -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
+26
View File
@@ -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",
]
+233
View File
@@ -0,0 +1,233 @@
"""Hosting helpers for QML views embedded into BEC widgets.
All QML views share one :class:`QQmlEngine` per application. The engine carries the
application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from
``bec_qthemes``), an ``image://material/<name>`` provider for Material icons and the import path
of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial
properties of the root item, typically a single ``backend`` QObject.
"""
from __future__ import annotations
from pathlib import Path
from urllib.parse import parse_qs
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal
from qtpy.QtGui import QColor, QPixmap
from qtpy.QtQml import QQmlEngine
from qtpy.QtQuick import QQuickImageProvider
from qtpy.QtQuickWidgets import QQuickWidget
from qtpy.QtWidgets import QApplication, QWidget
logger = bec_logger.logger
QML_IMPORT_PATH = Path(__file__).parent / "qml"
_DARK_FALLBACK = {
"PRIMARY": "#3b82f6",
"ON_PRIMARY": "#ffffff",
"BG": "#0f1115",
"CARD_BG": "#171a21",
"FIELD_BG": "#10131a",
"BORDER": "#2a2f3a",
"FG": "#e8ebf1",
"ACCENT_DEFAULT": "#8ab4f7",
"ACCENT_HIGHLIGHT": "#B53565",
"ACCENT_WARNING": "#EAC435",
"ACCENT_EMERGENCY": "#CC181E",
"ACCENT_SUCCESS": "#2CA58D",
}
def _blend(base: QColor, top: QColor, amount: float) -> QColor:
"""Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top)."""
return QColor.fromRgbF(
base.redF() + (top.redF() - base.redF()) * amount,
base.greenF() + (top.greenF() - base.greenF()) * amount,
base.blueF() + (top.blueF() - base.blueF()) * amount,
1.0,
)
class ThemeTokens:
"""Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports."""
def __init__(self):
app = QApplication.instance()
theme = getattr(app, "theme", None) if app is not None else None
name = getattr(theme, "theme", "dark") if theme is not None else "dark"
def color(key: str) -> QColor:
fallback = _DARK_FALLBACK.get(key, "#808080")
if theme is None:
return QColor(fallback)
return QColor(theme.color(key, fallback))
self.name: str = str(name)
self.bg = color("BG")
self.card = color("CARD_BG")
self.field = color("FIELD_BG")
self.border = color("BORDER")
self.fg = color("FG")
self.primary = color("PRIMARY")
self.on_primary = color("ON_PRIMARY")
self.accent = color("ACCENT_DEFAULT")
self.highlight = color("ACCENT_HIGHLIGHT")
self.warning = color("ACCENT_WARNING")
self.danger = color("ACCENT_EMERGENCY")
self.success = color("ACCENT_SUCCESS")
self.dark = self.bg.lightnessF() < 0.5
self.fg_muted = _blend(self.card, self.fg, 0.62)
self.fg_subtle = _blend(self.card, self.fg, 0.4)
self.hover = _blend(self.card, self.fg, 0.06)
self.pressed = _blend(self.card, self.fg, 0.12)
self.track = _blend(self.card, self.fg, 0.1)
def soft(self, color: QColor, amount: float = 0.16) -> QColor:
"""Tint ``color`` onto the card background, e.g. for chips and banners."""
return _blend(self.card, color, amount)
class QmlTheme(QObject):
"""Expose the BEC theme colours to QML as ``theme.<token>`` and follow theme changes."""
changed = Signal()
def __init__(self, parent: QObject | None = None):
super().__init__(parent)
self._tokens = ThemeTokens()
self._connected_theme = None
self._connect_theme()
def _connect_theme(self) -> None:
app = QApplication.instance()
theme = getattr(app, "theme", None) if app is not None else None
if theme is None or theme is self._connected_theme:
return
theme.theme_changed.connect(self.refresh)
self._connected_theme = theme
def refresh(self, *_args) -> None:
"""Re-read the tokens from the application theme."""
self._connect_theme()
self._tokens = ThemeTokens()
self.changed.emit()
# pylint: disable=missing-function-docstring
def _get(self, name: str) -> QColor:
return getattr(self._tokens, name)
name = Property(str, lambda self: self._tokens.name, notify=changed)
dark = Property(bool, lambda self: self._tokens.dark, notify=changed)
bg = Property(QColor, lambda self: self._get("bg"), notify=changed)
card = Property(QColor, lambda self: self._get("card"), notify=changed)
field = Property(QColor, lambda self: self._get("field"), notify=changed)
border = Property(QColor, lambda self: self._get("border"), notify=changed)
fg = Property(QColor, lambda self: self._get("fg"), notify=changed)
fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed)
fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed)
hover = Property(QColor, lambda self: self._get("hover"), notify=changed)
pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed)
track = Property(QColor, lambda self: self._get("track"), notify=changed)
primary = Property(QColor, lambda self: self._get("primary"), notify=changed)
onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed)
accent = Property(QColor, lambda self: self._get("accent"), notify=changed)
highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed)
warning = Property(QColor, lambda self: self._get("warning"), notify=changed)
danger = Property(QColor, lambda self: self._get("danger"), notify=changed)
success = Property(QColor, lambda self: self._get("success"), notify=changed)
class MaterialIconProvider(QQuickImageProvider):
"""Serve Material icons to QML as ``image://material/<name>?color=%23rrggbb&filled=1``."""
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Pixmap)
def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap:
name, _, query = icon_id.partition("?")
options = parse_qs(query)
color = options.get("color", [None])[0]
filled = options.get("filled", ["0"])[0] in ("1", "true")
target = requested_size if requested_size.isValid() else QSize(24, 24)
if target.width() <= 0 or target.height() <= 0:
target = QSize(24, 24)
pixmap = material_icon(name, size=target, color=color, filled=filled)
if size is not None:
size.setWidth(pixmap.width())
size.setHeight(pixmap.height())
return pixmap
_ENGINE: QQmlEngine | None = None
_THEME: QmlTheme | None = None
def quick_engine() -> QQmlEngine:
"""Return the application-wide QML engine used by all BEC QML views."""
global _ENGINE, _THEME # pylint: disable=global-statement
if _ENGINE is not None:
if _THEME._connected_theme is None: # pylint: disable=protected-access
# the theme was applied after the engine was created
_THEME.refresh()
return _ENGINE
app = QApplication.instance()
_ENGINE = QQmlEngine(app)
_ENGINE.addImportPath(str(QML_IMPORT_PATH))
_ENGINE.addImageProvider("material", MaterialIconProvider())
_THEME = QmlTheme(_ENGINE)
_ENGINE.rootContext().setContextProperty("theme", _THEME)
_ENGINE.warnings.connect(
lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings]
)
if app is not None:
app.aboutToQuit.connect(_drop_engine)
return _ENGINE
def _drop_engine() -> None:
global _ENGINE, _THEME # pylint: disable=global-statement
_ENGINE = None
_THEME = None
def create_quick_widget(
parent: QWidget, qml_file: str | Path, properties: dict | None = None
) -> QQuickWidget:
"""Create a QQuickWidget showing ``qml_file`` on the shared engine.
Args:
parent(QWidget): Parent widget, usually the BEC widget shell.
qml_file(str | Path): Path of the root QML file.
properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``.
Returns:
QQuickWidget: The view, already loaded. Load errors are logged.
"""
view = QQuickWidget(quick_engine(), parent)
view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
view.setClearColor(parent.palette().window().color())
if properties:
view.setInitialProperties(properties)
view.setSource(QUrl.fromLocalFile(str(qml_file)))
for error in view.errors():
logger.error(f"QML error in {qml_file}: {error.toString()}")
return view
def release_quick_widget(view: QQuickWidget | None) -> None:
"""Unload the QML scene of ``view`` before its backend objects are destroyed.
Without this, bindings of a still-loaded scene are re-evaluated against already deleted
backends during teardown and log ``TypeError`` warnings.
"""
if view is None:
return
try:
view.setSource(QUrl())
except RuntimeError:
# the C++ object is already gone
return
+83
View File
@@ -0,0 +1,83 @@
"""A small list model that feeds lists of dictionaries to QML views."""
from __future__ import annotations
from typing import Any, Sequence
from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal
class DictListModel(QAbstractListModel):
"""List model whose rows are plain dictionaries with a fixed set of keys.
Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is
unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes
instead of the delegates being rebuilt.
Args:
roles(Sequence[str]): Keys of the row dictionaries exposed as roles.
parent: Parent QObject.
"""
count_changed = Signal()
def __init__(self, roles: Sequence[str], parent=None):
super().__init__(parent)
self._keys = list(roles)
self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)}
self._items: list[dict[str, Any]] = []
# pylint: disable=invalid-name
def roleNames(self) -> dict[int, QByteArray]:
return {role: QByteArray(key.encode()) for role, key in self._roles.items()}
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._items)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if not index.isValid() or not 0 <= index.row() < len(self._items):
return None
key = self._roles.get(role)
if key is None:
return None
return self._items[index.row()].get(key)
@property
def items(self) -> list[dict[str, Any]]:
"""Copy of the current rows."""
return [dict(item) for item in self._items]
def set_items(self, items: Sequence[dict[str, Any]]) -> None:
"""Replace the rows, reusing existing rows where possible.
Args:
items(Sequence[dict]): New rows.
"""
items = [dict(item) for item in items]
old_count = len(self._items)
new_count = len(items)
common = min(old_count, new_count)
for row in range(common):
changed = [
role
for role, key in self._roles.items()
if self._items[row].get(key) != items[row].get(key)
]
self._items[row] = items[row]
if changed:
index = self.index(row, 0)
self.dataChanged.emit(index, index, changed)
if new_count > old_count:
self.beginInsertRows(QModelIndex(), old_count, new_count - 1)
self._items.extend(items[old_count:])
self.endInsertRows()
elif new_count < old_count:
self.beginRemoveRows(QModelIndex(), new_count, old_count - 1)
del self._items[new_count:]
self.endRemoveRows()
if new_count != old_count:
self.count_changed.emit()
count = Property(int, lambda self: len(self._items), notify=count_changed)
@@ -0,0 +1,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
}
}
}
@@ -0,0 +1,12 @@
import QtQuick
// Background used by all input controls: field colour, border, focus and error states.
Rectangle {
property bool focused: false
property bool invalid: false
property bool hovered: false
radius: 6
color: theme.field
border.width: focused || invalid ? 2 : 1
border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border
}
@@ -0,0 +1,25 @@
import QtQuick
// Material icon served by the "material" image provider, tinted with the given colour.
Item {
id: root
property string name: ""
property color color: theme.fg
property bool filled: false
property int size: 18
implicitWidth: size
implicitHeight: size
Image {
anchors.centerIn: parent
width: root.size
height: root.size
sourceSize.width: root.size * 2
sourceSize.height: root.size * 2
smooth: true
fillMode: Image.PreserveAspectFit
source: root.name === "" ? "" : "image://material/" + root.name + "?color="
+ encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "")
}
}
@@ -0,0 +1,36 @@
import QtQuick
import QtQuick.Controls.Basic
// Flat, square icon button with hover and pressed feedback and a tooltip.
AbstractButton {
id: root
property string iconName: ""
property color iconColor: theme.fgMuted
property string tip: ""
property int iconSize: 18
implicitWidth: 30
implicitHeight: 30
hoverEnabled: true
focusPolicy: Qt.TabFocus
opacity: enabled ? 1.0 : 0.4
Accessible.name: tip
background: Rectangle {
radius: 6
color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent")
border.width: root.visualFocus ? 1 : 0
border.color: theme.primary
}
contentItem: Item {
Icon {
anchors.centerIn: parent
name: root.iconName
size: root.iconSize
color: root.checked ? theme.primary : root.iconColor
}
}
ToolTip.visible: tip !== "" && hovered
ToolTip.delay: 500
ToolTip.text: tip
}
@@ -0,0 +1,38 @@
import QtQuick
import QtQuick.Controls.Basic
// Single-line text input with optional unit suffix and error state.
TextField {
id: root
property bool invalid: false
property string suffix: ""
implicitHeight: 32
implicitWidth: 120
color: theme.fg
placeholderTextColor: theme.fgSubtle
selectionColor: theme.primary
selectedTextColor: theme.onPrimary
font.pixelSize: 13
leftPadding: 9
rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9
selectByMouse: true
hoverEnabled: true
opacity: enabled ? 1.0 : 0.5
background: FieldFrame {
focused: root.activeFocus
invalid: root.invalid
hovered: root.hovered
}
Text {
id: suffixLabel
visible: root.suffix !== ""
text: root.suffix
color: theme.fgSubtle
font.pixelSize: 12
anchors.right: parent.right
anchors.rightMargin: 9
anchors.verticalCenter: parent.verticalCenter
}
}
@@ -0,0 +1,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
}
}
}
@@ -0,0 +1,40 @@
import QtQuick
// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving".
Rectangle {
id: root
property string text: ""
property color tone: theme.fgMuted
property bool pulse: false
implicitHeight: 22
implicitWidth: label.implicitWidth + 26
radius: height / 2
color: Qt.rgba(tone.r, tone.g, tone.b, 0.16)
Rectangle {
id: dot
width: 7; height: 7; radius: 3.5
color: root.tone
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
SequentialAnimation on opacity {
running: root.pulse
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad }
}
}
Text {
id: label
anchors.left: dot.right
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: root.text
color: root.tone
font.pixelSize: 12
font.weight: Font.DemiBold
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Controls.Basic
// Toggle switch for boolean parameters.
Switch {
id: root
implicitHeight: 32
hoverEnabled: true
padding: 0
indicator: Rectangle {
implicitWidth: 38
implicitHeight: 22
x: root.leftPadding
y: (root.height - height) / 2
radius: 11
color: root.checked ? theme.primary : theme.track
border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
x: root.checked ? parent.width - width - 3 : 3
y: 3
width: 16; height: 16; radius: 8
color: root.checked ? theme.onPrimary : theme.fgMuted
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
}
}
contentItem: Text {
leftPadding: root.indicator.width + 8
text: root.text
color: theme.fg
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
@@ -0,0 +1,65 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost".
AbstractButton {
id: root
property string variant: "neutral"
property string iconName: ""
property string tip: ""
property bool busy: false
readonly property color baseColor: variant === "primary" ? theme.primary
: variant === "danger" ? theme.danger
: variant === "success" ? theme.success
: "transparent"
readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success"
readonly property color labelColor: solid ? "white" : theme.fg
implicitHeight: 32
implicitWidth: Math.max(64, row.implicitWidth + 24)
hoverEnabled: true
opacity: enabled ? 1.0 : 0.45
Accessible.name: text
background: Rectangle {
radius: 6
color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor)
: (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent")
border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0
border.color: root.visualFocus ? theme.primary : theme.border
Behavior on color { ColorAnimation { duration: 90 } }
}
contentItem: Item {
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
RowLayout {
id: row
anchors.centerIn: parent
spacing: 6
BusyIndicator {
visible: root.busy
running: root.busy
Layout.preferredWidth: 16
Layout.preferredHeight: 16
}
Icon {
visible: root.iconName !== "" && !root.busy
name: root.iconName
color: root.labelColor
size: 16
}
Text {
text: root.text
color: root.labelColor
font.pixelSize: 13
font.weight: root.solid ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
}
}
ToolTip.visible: tip !== "" && hovered
ToolTip.delay: 500
ToolTip.text: tip
}
+10
View File
@@ -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
+326
View File
@@ -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
@@ -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.
@@ -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
@@ -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)
@@ -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),
),
),
)
@@ -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)
@@ -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()
@@ -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)
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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)
@@ -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)
@@ -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()
@@ -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,
+12
View File
@@ -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"
+247
View File
@@ -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()