mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 16:21:27 +01:00
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:
30 files changed
+3652
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user