feat(main_app): add Ctrl+K command palette with QWidget and QML views

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QVD58dnQCRQSxSJLj5enEZ
This commit is contained in:
Claude committed 2026-10-09 21:37:29 +00:00
1 parent 703513b175
commit 1fe19099a1
24 files changed
+3051 -2

No files matched your search

+42
View File
@@ -24,6 +24,11 @@ from bec_widgets.utils.screen_utils import (
)
from bec_widgets.widgets.containers.dock_area.profile_utils import is_experimental_features_enabled
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.utility.command_palette.command_palette import SHORTCUTS, CommandPalette
from bec_widgets.widgets.utility.command_palette.palette_sources import (
main_app_commands,
menu_commands,
)
startup_profiler.mark("module imports")
@@ -70,6 +75,9 @@ class BECMainApp(BECMainWindow):
self._setup_guided_tour()
startup_profiler.mark("guided tour")
self._setup_command_palette()
startup_profiler.mark("command palette")
def showEvent(self, event):
super().showEvent(event)
if self._launcher_ready_notified:
@@ -391,7 +399,41 @@ class BECMainApp(BECMainWindow):
tour_action.setShortcut("F1") # Add keyboard shortcut
help_menu.addAction(tour_action)
def _setup_command_palette(self):
"""Create the Ctrl+K command palette and its entry in the View menu."""
self._command_palette_action = QAction("Command Palette…", self)
self._command_palette_action.setIcon(material_icon("search"))
self._command_palette_action.setShortcuts(list(SHORTCUTS))
self.command_palette = CommandPalette(
self,
sources=[
lambda: main_app_commands(self),
lambda: menu_commands(self.menuBar(), skip=[self._command_palette_action]),
],
)
self._command_palette_action.triggered.connect(self.command_palette.toggle)
for action in self.menuBar().actions():
if action.text() == "View" and action.menu() is not None:
view_menu = action.menu()
first = view_menu.actions()[0] if view_menu.actions() else None
view_menu.insertAction(first, self._command_palette_action)
view_menu.insertSeparator(first)
break
else:
self.addAction(self._command_palette_action)
def open_command_palette(self, text: str = "") -> None:
"""
Open the command palette.
Args:
text(str): Initial search text; start it with ``>``, ``+``, ``@`` or ``#`` to search
only actions, widgets, devices or workspaces.
"""
self.command_palette.open_palette(text)
def cleanup(self):
self.command_palette.cleanup()
for view_id, idx in self._view_index.items():
view = self.stack.widget(idx)
view.close()
@@ -2,6 +2,11 @@ from qtpy.QtWidgets import QWidget
from bec_widgets.applications.views.view import ViewBase
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
from bec_widgets.widgets.utility.command_palette.palette_core import PaletteCommand
from bec_widgets.widgets.utility.command_palette.palette_sources import (
device_commands,
dock_area_commands,
)
class DockAreaView(ViewBase):
@@ -29,3 +34,11 @@ class DockAreaView(ViewBase):
rpc_exposed=False,
)
self.set_content(self.dock_area)
def palette_commands(self) -> list[PaletteCommand]:
"""Commands this view adds to the main app's command palette: widgets to add,
workspaces to open and devices to control or plot."""
return [
*dock_area_commands(self.dock_area, self.activate),
*device_commands(self.dock_area, self.activate),
]
+26
View File
@@ -0,0 +1,26 @@
"""Shared plumbing for widgets that render their content with Qt Quick (QML).
The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering
layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and
driven by a small Python backend object.
"""
from bec_widgets.utils.quick.host import (
QML_IMPORT_PATH,
MaterialIconProvider,
QmlTheme,
create_quick_widget,
quick_engine,
release_quick_widget,
)
from bec_widgets.utils.quick.list_model import DictListModel
__all__ = [
"QML_IMPORT_PATH",
"DictListModel",
"MaterialIconProvider",
"QmlTheme",
"create_quick_widget",
"quick_engine",
"release_quick_widget",
]
+233
View File
@@ -0,0 +1,233 @@
"""Hosting helpers for QML views embedded into BEC widgets.
All QML views share one :class:`QQmlEngine` per application. The engine carries the
application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from
``bec_qthemes``), an ``image://material/<name>`` provider for Material icons and the import path
of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial
properties of the root item, typically a single ``backend`` QObject.
"""
from __future__ import annotations
from pathlib import Path
from urllib.parse import parse_qs
from bec_lib.logger import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal
from qtpy.QtGui import QColor, QPixmap
from qtpy.QtQml import QQmlEngine
from qtpy.QtQuick import QQuickImageProvider
from qtpy.QtQuickWidgets import QQuickWidget
from qtpy.QtWidgets import QApplication, QWidget
logger = bec_logger.logger
QML_IMPORT_PATH = Path(__file__).parent / "qml"
_DARK_FALLBACK = {
"PRIMARY": "#3b82f6",
"ON_PRIMARY": "#ffffff",
"BG": "#0f1115",
"CARD_BG": "#171a21",
"FIELD_BG": "#10131a",
"BORDER": "#2a2f3a",
"FG": "#e8ebf1",
"ACCENT_DEFAULT": "#8ab4f7",
"ACCENT_HIGHLIGHT": "#B53565",
"ACCENT_WARNING": "#EAC435",
"ACCENT_EMERGENCY": "#CC181E",
"ACCENT_SUCCESS": "#2CA58D",
}
def _blend(base: QColor, top: QColor, amount: float) -> QColor:
"""Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top)."""
return QColor.fromRgbF(
base.redF() + (top.redF() - base.redF()) * amount,
base.greenF() + (top.greenF() - base.greenF()) * amount,
base.blueF() + (top.blueF() - base.blueF()) * amount,
1.0,
)
class ThemeTokens:
"""Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports."""
def __init__(self):
app = QApplication.instance()
theme = getattr(app, "theme", None) if app is not None else None
name = getattr(theme, "theme", "dark") if theme is not None else "dark"
def color(key: str) -> QColor:
fallback = _DARK_FALLBACK.get(key, "#808080")
if theme is None:
return QColor(fallback)
return QColor(theme.color(key, fallback))
self.name: str = str(name)
self.bg = color("BG")
self.card = color("CARD_BG")
self.field = color("FIELD_BG")
self.border = color("BORDER")
self.fg = color("FG")
self.primary = color("PRIMARY")
self.on_primary = color("ON_PRIMARY")
self.accent = color("ACCENT_DEFAULT")
self.highlight = color("ACCENT_HIGHLIGHT")
self.warning = color("ACCENT_WARNING")
self.danger = color("ACCENT_EMERGENCY")
self.success = color("ACCENT_SUCCESS")
self.dark = self.bg.lightnessF() < 0.5
self.fg_muted = _blend(self.card, self.fg, 0.62)
self.fg_subtle = _blend(self.card, self.fg, 0.4)
self.hover = _blend(self.card, self.fg, 0.06)
self.pressed = _blend(self.card, self.fg, 0.12)
self.track = _blend(self.card, self.fg, 0.1)
def soft(self, color: QColor, amount: float = 0.16) -> QColor:
"""Tint ``color`` onto the card background, e.g. for chips and banners."""
return _blend(self.card, color, amount)
class QmlTheme(QObject):
"""Expose the BEC theme colours to QML as ``theme.<token>`` and follow theme changes."""
changed = Signal()
def __init__(self, parent: QObject | None = None):
super().__init__(parent)
self._tokens = ThemeTokens()
self._connected_theme = None
self._connect_theme()
def _connect_theme(self) -> None:
app = QApplication.instance()
theme = getattr(app, "theme", None) if app is not None else None
if theme is None or theme is self._connected_theme:
return
theme.theme_changed.connect(self.refresh)
self._connected_theme = theme
def refresh(self, *_args) -> None:
"""Re-read the tokens from the application theme."""
self._connect_theme()
self._tokens = ThemeTokens()
self.changed.emit()
# pylint: disable=missing-function-docstring
def _get(self, name: str) -> QColor:
return getattr(self._tokens, name)
name = Property(str, lambda self: self._tokens.name, notify=changed)
dark = Property(bool, lambda self: self._tokens.dark, notify=changed)
bg = Property(QColor, lambda self: self._get("bg"), notify=changed)
card = Property(QColor, lambda self: self._get("card"), notify=changed)
field = Property(QColor, lambda self: self._get("field"), notify=changed)
border = Property(QColor, lambda self: self._get("border"), notify=changed)
fg = Property(QColor, lambda self: self._get("fg"), notify=changed)
fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed)
fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed)
hover = Property(QColor, lambda self: self._get("hover"), notify=changed)
pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed)
track = Property(QColor, lambda self: self._get("track"), notify=changed)
primary = Property(QColor, lambda self: self._get("primary"), notify=changed)
onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed)
accent = Property(QColor, lambda self: self._get("accent"), notify=changed)
highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed)
warning = Property(QColor, lambda self: self._get("warning"), notify=changed)
danger = Property(QColor, lambda self: self._get("danger"), notify=changed)
success = Property(QColor, lambda self: self._get("success"), notify=changed)
class MaterialIconProvider(QQuickImageProvider):
"""Serve Material icons to QML as ``image://material/<name>?color=%23rrggbb&filled=1``."""
def __init__(self):
super().__init__(QQuickImageProvider.ImageType.Pixmap)
def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap:
name, _, query = icon_id.partition("?")
options = parse_qs(query)
color = options.get("color", [None])[0]
filled = options.get("filled", ["0"])[0] in ("1", "true")
target = requested_size if requested_size.isValid() else QSize(24, 24)
if target.width() <= 0 or target.height() <= 0:
target = QSize(24, 24)
pixmap = material_icon(name, size=target, color=color, filled=filled)
if size is not None:
size.setWidth(pixmap.width())
size.setHeight(pixmap.height())
return pixmap
_ENGINE: QQmlEngine | None = None
_THEME: QmlTheme | None = None
def quick_engine() -> QQmlEngine:
"""Return the application-wide QML engine used by all BEC QML views."""
global _ENGINE, _THEME # pylint: disable=global-statement
if _ENGINE is not None:
if _THEME._connected_theme is None: # pylint: disable=protected-access
# the theme was applied after the engine was created
_THEME.refresh()
return _ENGINE
app = QApplication.instance()
_ENGINE = QQmlEngine(app)
_ENGINE.addImportPath(str(QML_IMPORT_PATH))
_ENGINE.addImageProvider("material", MaterialIconProvider())
_THEME = QmlTheme(_ENGINE)
_ENGINE.rootContext().setContextProperty("theme", _THEME)
_ENGINE.warnings.connect(
lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings]
)
if app is not None:
app.aboutToQuit.connect(_drop_engine)
return _ENGINE
def _drop_engine() -> None:
global _ENGINE, _THEME # pylint: disable=global-statement
_ENGINE = None
_THEME = None
def create_quick_widget(
parent: QWidget, qml_file: str | Path, properties: dict | None = None
) -> QQuickWidget:
"""Create a QQuickWidget showing ``qml_file`` on the shared engine.
Args:
parent(QWidget): Parent widget, usually the BEC widget shell.
qml_file(str | Path): Path of the root QML file.
properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``.
Returns:
QQuickWidget: The view, already loaded. Load errors are logged.
"""
view = QQuickWidget(quick_engine(), parent)
view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView)
view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False)
view.setClearColor(parent.palette().window().color())
if properties:
view.setInitialProperties(properties)
view.setSource(QUrl.fromLocalFile(str(qml_file)))
for error in view.errors():
logger.error(f"QML error in {qml_file}: {error.toString()}")
return view
def release_quick_widget(view: QQuickWidget | None) -> None:
"""Unload the QML scene of ``view`` before its backend objects are destroyed.
Without this, bindings of a still-loaded scene are re-evaluated against already deleted
backends during teardown and log ``TypeError`` warnings.
"""
if view is None:
return
try:
view.setSource(QUrl())
except RuntimeError:
# the C++ object is already gone
return
+83
View File
@@ -0,0 +1,83 @@
"""A small list model that feeds lists of dictionaries to QML views."""
from __future__ import annotations
from typing import Any, Sequence
from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal
class DictListModel(QAbstractListModel):
"""List model whose rows are plain dictionaries with a fixed set of keys.
Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is
unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes
instead of the delegates being rebuilt.
Args:
roles(Sequence[str]): Keys of the row dictionaries exposed as roles.
parent: Parent QObject.
"""
count_changed = Signal()
def __init__(self, roles: Sequence[str], parent=None):
super().__init__(parent)
self._keys = list(roles)
self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)}
self._items: list[dict[str, Any]] = []
# pylint: disable=invalid-name
def roleNames(self) -> dict[int, QByteArray]:
return {role: QByteArray(key.encode()) for role, key in self._roles.items()}
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
if parent.isValid():
return 0
return len(self._items)
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if not index.isValid() or not 0 <= index.row() < len(self._items):
return None
key = self._roles.get(role)
if key is None:
return None
return self._items[index.row()].get(key)
@property
def items(self) -> list[dict[str, Any]]:
"""Copy of the current rows."""
return [dict(item) for item in self._items]
def set_items(self, items: Sequence[dict[str, Any]]) -> None:
"""Replace the rows, reusing existing rows where possible.
Args:
items(Sequence[dict]): New rows.
"""
items = [dict(item) for item in items]
old_count = len(self._items)
new_count = len(items)
common = min(old_count, new_count)
for row in range(common):
changed = [
role
for role, key in self._roles.items()
if self._items[row].get(key) != items[row].get(key)
]
self._items[row] = items[row]
if changed:
index = self.index(row, 0)
self.dataChanged.emit(index, index, changed)
if new_count > old_count:
self.beginInsertRows(QModelIndex(), old_count, new_count - 1)
self._items.extend(items[old_count:])
self.endInsertRows()
elif new_count < old_count:
self.beginRemoveRows(QModelIndex(), new_count, old_count - 1)
del self._items[new_count:]
self.endRemoveRows()
if new_count != old_count:
self.count_changed.emit()
count = Property(int, lambda self: len(self._items), notify=count_changed)
@@ -0,0 +1,56 @@
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
// Let the content column take the remaining height, e.g. for a list that fills the card.
property bool fillContent: false
color: theme.card
radius: 10
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
Layout.fillHeight: root.fillContent
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,88 @@
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
property string placeholder: ""
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
placeholderText: root.placeholder
placeholderTextColor: theme.fgSubtle
enabled: root.enabled
color: theme.fg
selectionColor: theme.primary
selectedTextColor: theme.onPrimary
font: root.font
verticalAlignment: Text.AlignVCenter
background: null
selectByMouse: true
// forward clicks to the combo when it is not editable
TapHandler { enabled: !root.editable; onTapped: root.popup.visible ? root.popup.close() : root.popup.open() }
}
indicator: Icon {
x: root.width - width - 6
y: (root.height - height) / 2
name: "expand_more"
size: 18
color: theme.fgMuted
}
delegate: ItemDelegate {
id: option
required property int index
required property var modelData
width: ListView.view ? ListView.view.width : root.width
height: 30
highlighted: root.highlightedIndex === index
contentItem: Text {
text: option.modelData === "" ? "None" : option.modelData
color: option.modelData === "" ? theme.fgSubtle : theme.fg
font.pixelSize: 13
elide: Text.ElideRight
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
color: option.highlighted ? theme.hover : "transparent"
Rectangle {
width: 3; height: parent.height - 10; radius: 1.5
anchors.verticalCenter: parent.verticalCenter
color: theme.primary
visible: root.currentIndex === option.index
}
}
}
popup: Popup {
y: root.height + 2
width: root.width
implicitHeight: Math.min(contentItem.implicitHeight + 8, 280)
padding: 4
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: root.popup.visible ? root.delegateModel : null
currentIndex: root.highlightedIndex
ScrollIndicator.vertical: ScrollIndicator {}
}
background: Rectangle {
color: theme.card
border.color: theme.border
radius: 8
}
}
}
@@ -0,0 +1,40 @@
import QtQuick
// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving".
Rectangle {
id: root
property string text: ""
property color tone: theme.fgMuted
property bool pulse: false
implicitHeight: 22
implicitWidth: label.implicitWidth + 26
radius: height / 2
color: Qt.rgba(tone.r, tone.g, tone.b, 0.16)
Rectangle {
id: dot
width: 7; height: 7; radius: 3.5
color: root.tone
anchors.left: parent.left
anchors.leftMargin: 9
anchors.verticalCenter: parent.verticalCenter
SequentialAnimation on opacity {
running: root.pulse
loops: Animation.Infinite
alwaysRunToEnd: true
NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad }
NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad }
}
}
Text {
id: label
anchors.left: dot.right
anchors.leftMargin: 6
anchors.verticalCenter: parent.verticalCenter
text: root.text
color: root.tone
font.pixelSize: 12
font.weight: Font.DemiBold
}
}
@@ -0,0 +1,34 @@
import QtQuick
import QtQuick.Controls.Basic
// Toggle switch for boolean parameters.
Switch {
id: root
implicitHeight: 32
hoverEnabled: true
padding: 0
indicator: Rectangle {
implicitWidth: 38
implicitHeight: 22
x: root.leftPadding
y: (root.height - height) / 2
radius: 11
color: root.checked ? theme.primary : theme.track
border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
x: root.checked ? parent.width - width - 3 : 3
y: 3
width: 16; height: 16; radius: 8
color: root.checked ? theme.onPrimary : theme.fgMuted
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
}
}
contentItem: Text {
leftPadding: root.indicator.width + 8
text: root.text
color: theme.fg
font.pixelSize: 13
verticalAlignment: Text.AlignVCenter
}
}
@@ -0,0 +1,65 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost".
AbstractButton {
id: root
property string variant: "neutral"
property string iconName: ""
property string tip: ""
property bool busy: false
readonly property color baseColor: variant === "primary" ? theme.primary
: variant === "danger" ? theme.danger
: variant === "success" ? theme.success
: "transparent"
readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success"
readonly property color labelColor: solid ? "white" : theme.fg
implicitHeight: 32
implicitWidth: Math.max(64, row.implicitWidth + 24)
hoverEnabled: true
opacity: enabled ? 1.0 : 0.45
Accessible.name: text
background: Rectangle {
radius: 6
color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor)
: (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent")
border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0
border.color: root.visualFocus ? theme.primary : theme.border
Behavior on color { ColorAnimation { duration: 90 } }
}
contentItem: Item {
implicitWidth: row.implicitWidth
implicitHeight: row.implicitHeight
RowLayout {
id: row
anchors.centerIn: parent
spacing: 6
BusyIndicator {
visible: root.busy
running: root.busy
Layout.preferredWidth: 16
Layout.preferredHeight: 16
}
Icon {
visible: root.iconName !== "" && !root.busy
name: root.iconName
color: root.labelColor
size: 16
}
Text {
text: root.text
color: root.labelColor
font.pixelSize: 13
font.weight: root.solid ? Font.DemiBold : Font.Medium
elide: Text.ElideRight
}
}
}
ToolTip.visible: tip !== "" && hovered
ToolTip.delay: 500
ToolTip.text: tip
}
+10
View File
@@ -0,0 +1,10 @@
module BecUi
Icon 1.0 Icon.qml
IconButton 1.0 IconButton.qml
TextButton 1.0 TextButton.qml
StatusPill 1.0 StatusPill.qml
Card 1.0 Card.qml
FieldFrame 1.0 FieldFrame.qml
InputField 1.0 InputField.qml
SelectField 1.0 SelectField.qml
SwitchField 1.0 SwitchField.qml
@@ -351,9 +351,14 @@ class BECDockArea(DockAreaWidget):
# Toolbar Setup
################################################################################
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
@staticmethod
def widget_catalog() -> dict[str, dict[str, tuple[str, str, str]]]:
"""Widgets offered by the toolbar, grouped as in its "Add ..." menus.
Returns:
dict[str, dict[str, tuple[str, str, str]]]: Group label mapped to
``{action_id: (icon_name, label, widget_type)}``.
"""
plot_actions = {
"waveform": (widget_icons["Waveform"], "Waveform", "Waveform"),
"scatter_waveform": (
@@ -399,6 +404,19 @@ class BECDockArea(DockAreaWidget):
),
}
catalog = {"Plots": plot_actions, "Device Control": device_actions, "Utils": util_actions}
plugin_actions = _plugin_toolbar_actions()
if plugin_actions:
catalog["Plugins"] = plugin_actions
return catalog
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
catalog = self.widget_catalog()
plot_actions = catalog["Plots"]
device_actions = catalog["Device Control"]
util_actions = catalog["Utils"]
# Create expandable menu actions (original behavior)
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
self.toolbar.components.add_safe(
@@ -0,0 +1 @@
"""Ctrl+K command palette with a QWidget and a QML rendering of the same search card."""
@@ -0,0 +1,200 @@
"""Ctrl+K command palette overlay for BEC windows.
The overlay dims the window and shows a search card near its top. The card is rendered either
with QWidgets (:mod:`command_palette_qwidget`) or with Qt Quick (:mod:`command_palette_qml`);
both drive the same :class:`CommandPaletteController`, so search, ranking and keyboard handling
are identical.
Typical use::
palette = CommandPalette(window, sources=[lambda: my_commands])
palette.install_shortcuts()
"""
from __future__ import annotations
import os
from typing import Iterable, Literal
from qtpy.QtCore import QEvent, QObject, QRect, QRectF, Qt, Signal
from qtpy.QtGui import QColor, QKeySequence, QPainter, QShortcut
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.quick.host import ThemeTokens
from bec_widgets.widgets.utility.command_palette.palette_core import (
CommandPaletteController,
CommandSource,
)
Flavour = Literal["qwidget", "qml"]
FLAVOUR_ENV = "BEC_COMMAND_PALETTE"
SHORTCUTS = ("Ctrl+K", "Ctrl+Shift+P")
CARD_WIDTH = 640
CARD_HEIGHT = 440
SHADOW_LAYERS = 6
def default_flavour() -> Flavour:
"""Renderer chosen by the ``BEC_COMMAND_PALETTE`` environment variable (``qwidget``)."""
value = os.environ.get(FLAVOUR_ENV, "qwidget").strip().lower()
return "qml" if value in ("qml", "quick") else "qwidget"
class CommandPalette(QWidget):
"""Window overlay hosting the command palette card.
Args:
window(QWidget): Window to cover, usually the main window.
sources(Iterable[CommandSource]): Command sources, called each time the palette opens.
flavour(Flavour | None): ``"qwidget"`` or ``"qml"``; defaults to :func:`default_flavour`.
"""
opened = Signal()
closed = Signal()
def __init__(
self, window: QWidget, sources: Iterable[CommandSource] = (), flavour: Flavour | None = None
):
super().__init__(window)
self.setObjectName("CommandPalette")
self.flavour: Flavour = flavour or default_flavour()
self.controller = CommandPaletteController(sources, self)
self.controller.accepted.connect(self.close_palette)
self.controller.dismissRequested.connect(self.close_palette)
self._previous_focus: QWidget | None = None
self._shortcuts: list[QShortcut] = []
# imported lazily so the QWidget flavour does not load Qt Quick
# pylint: disable=import-outside-toplevel
if self.flavour == "qml":
from bec_widgets.widgets.utility.command_palette.command_palette_qml import (
PaletteCardQuick as Card,
)
else:
from bec_widgets.widgets.utility.command_palette.command_palette_qwidget import (
PaletteCardWidget as Card,
)
self.card = Card(self.controller, self)
window.installEventFilter(self)
self.hide()
# ------------------------------------------------------------------ public API
def install_shortcuts(self, sequences: Iterable[str] = SHORTCUTS) -> list[QShortcut]:
"""Open the palette with ``sequences`` anywhere in the window (Ctrl+K, Ctrl+Shift+P)."""
window = self.parentWidget()
for sequence in sequences:
shortcut = QShortcut(QKeySequence(sequence), window)
shortcut.setContext(Qt.ShortcutContext.WindowShortcut)
shortcut.activated.connect(self.toggle)
self._shortcuts.append(shortcut)
return self._shortcuts
def is_open(self) -> bool:
"""Whether the palette is currently shown."""
return self.isVisible()
def toggle(self) -> None:
"""Open the palette, or close it if it is already open."""
if self.is_open():
self.close_palette()
else:
self.open_palette()
def open_palette(self, text: str = "") -> None:
"""Show the palette with a fresh command list.
Args:
text(str): Initial query; a leading ``>``, ``+``, ``@`` or ``#`` selects a scope.
"""
tokens = ThemeTokens()
self.controller.set_highlight_color(tokens.primary.name())
self.controller.reset()
self.controller.refresh()
if text:
self.controller.set_query(text)
self.card.refresh_theme(tokens)
focus = QApplication.focusWidget()
if focus is not None and focus is not self and not self.isAncestorOf(focus):
self._previous_focus = focus
self._update_geometry()
self.show()
self.raise_()
self.card.focus_input()
self.opened.emit()
def close_palette(self) -> None:
"""Hide the palette and give the focus back to where it was."""
if not self.isVisible():
return
self.hide()
previous, self._previous_focus = self._previous_focus, None
if previous is not None:
try:
previous.setFocus(Qt.FocusReason.PopupFocusReason)
except RuntimeError:
pass # the widget was deleted meanwhile
self.closed.emit()
def card_geometry(self) -> QRect:
"""Geometry of the visible card (without shadow margins) in overlay coordinates."""
width = min(CARD_WIDTH, max(320, self.width() - 32))
height = min(CARD_HEIGHT, max(200, self.height() - 48))
top = max(24, int(self.height() * 0.12))
top = min(top, max(8, self.height() - height - 8))
return QRect((self.width() - width) // 2, top, width, height)
# ------------------------------------------------------------------ Qt overrides
def _update_geometry(self) -> None:
window = self.parentWidget()
if window is None:
return
self.setGeometry(window.rect())
margin = getattr(self.card, "shadow_margin", 0)
self.card.setGeometry(self.card_geometry().adjusted(-margin, -margin, margin, margin))
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # pylint: disable=invalid-name
if watched is self.parentWidget() and event.type() == QEvent.Type.Resize:
if self.isVisible():
self._update_geometry()
return super().eventFilter(watched, event)
def paintEvent(self, _event): # pylint: disable=invalid-name
tokens = ThemeTokens()
painter = QPainter(self)
painter.fillRect(self.rect(), QColor(0, 0, 0, 120 if tokens.dark else 70))
if not getattr(self.card, "shadow_margin", 0):
# layered soft shadow below the QWidget card, the same recipe the QML card uses;
# a QGraphicsDropShadowEffect would re-render the whole card on every list repaint
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
card = QRectF(self.card_geometry())
for i in range(SHADOW_LAYERS):
alpha = (0.11 if tokens.dark else 0.045) * (1 - i / (SHADOW_LAYERS + 1))
painter.setBrush(QColor(0, 0, 0, int(255 * alpha)))
rect = card.adjusted(-3 * i, 10 - 2 * i, 3 * i, 10 + 3 * i)
painter.drawRoundedRect(rect, 12 + 3 * i, 12 + 3 * i)
painter.end()
def mousePressEvent(self, event): # pylint: disable=invalid-name
if not self.card_geometry().contains(event.position().toPoint()):
self.close_palette()
event.accept()
return
super().mousePressEvent(event)
def keyPressEvent(self, event): # pylint: disable=invalid-name
if event.key() == Qt.Key.Key_Escape:
self.close_palette()
event.accept()
return
super().keyPressEvent(event)
def cleanup(self) -> None:
"""Release the card, e.g. unload the QML scene before the controller is deleted."""
window = self.parentWidget()
if window is not None:
window.removeEventFilter(self)
release = getattr(self.card, "release", None)
if callable(release):
release()
@@ -0,0 +1,59 @@
"""Qt Quick rendering of the command palette card.
Twin of :mod:`command_palette_qwidget`. The QML scene in ``qml/CommandPaletteView.qml`` binds to
the shared :class:`CommandPaletteController` and its list model; it draws its own shadow inside
:attr:`PaletteCardQuick.shadow_margin`.
"""
from __future__ import annotations
from pathlib import Path
from qtpy.QtCore import QMetaObject, Qt
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.utils.quick.host import ThemeTokens, create_quick_widget, release_quick_widget
from bec_widgets.widgets.utility.command_palette.palette_core import CommandPaletteController
QML_FILE = Path(__file__).parent / "qml" / "CommandPaletteView.qml"
class PaletteCardQuick(QWidget):
"""Hosts the QML palette card in a transparent :class:`QQuickWidget`.
Args:
controller(CommandPaletteController): Shared search state.
parent(QWidget | None): The hosting overlay.
"""
shadow_margin = 24
def __init__(self, controller: CommandPaletteController, parent: QWidget | None = None):
super().__init__(parent)
self.controller = controller
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(
self,
QML_FILE,
{"backend": controller, "paletteModel": controller.model, "margin": self.shadow_margin},
)
# let the dimmed window show through the rounded corners and the shadow
self.view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, True)
self.view.setClearColor(QColor(Qt.GlobalColor.transparent))
layout.addWidget(self.view)
def focus_input(self) -> None:
"""Give the keyboard focus to the QML search field."""
self.view.setFocus(Qt.FocusReason.PopupFocusReason)
root = self.view.rootObject()
if root is not None:
QMetaObject.invokeMethod(root, "opened")
def refresh_theme(self, _tokens: ThemeTokens) -> None:
"""The QML ``theme`` object follows the application theme on its own."""
def release(self) -> None:
"""Unload the scene before the controller goes away."""
release_quick_widget(self.view)
@@ -0,0 +1,462 @@
"""QWidget rendering of the command palette card.
Twin of ``qml/CommandPaletteView.qml``: same layout, colours and keyboard handling, painted with
a :class:`QStyledItemDelegate` over the controller's list model.
"""
from __future__ import annotations
from functools import lru_cache
from bec_qthemes import material_icon
from qtpy.QtCore import QEvent, QModelIndex, QObject, QPointF, QRectF, QSize, Qt
from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPixmap
from qtpy.QtWidgets import (
QAbstractButton,
QAbstractItemView,
QButtonGroup,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListView,
QStackedWidget,
QStyle,
QStyledItemDelegate,
QStyleOptionViewItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.quick.host import ThemeTokens
from bec_widgets.widgets.utility.command_palette.palette_core import (
ROLES,
SCOPES,
CommandPaletteController,
)
ROW_HEIGHT = 46
HEADER_HEIGHT = 28
PAGE_STEP = 8
def _role(key: str) -> int:
return Qt.ItemDataRole.UserRole + 1 + ROLES.index(key)
@lru_cache(maxsize=512)
def _icon(name: str, color: str, size: int) -> QPixmap:
pixmap = material_icon(name, size=(size * 2, size * 2), color=color, filled=False)
pixmap.setDevicePixelRatio(2.0)
return pixmap
def _font(pixel_size: int, weight: QFont.Weight = QFont.Weight.Normal) -> QFont:
font = QFont()
font.setPixelSize(pixel_size)
font.setWeight(weight)
return font
def draw_keycap(painter: QPainter, right: float, center_y: float, text: str, tokens) -> float:
"""Paint a small key label ending at ``right``; return its left edge."""
font = _font(11, QFont.Weight.DemiBold)
width = QFontMetrics(font).horizontalAdvance(text) + 12
rect = QRectF(right - width, center_y - 10, width, 20)
painter.setPen(tokens.border)
painter.setBrush(tokens.field)
painter.drawRoundedRect(rect, 5, 5)
painter.setPen(tokens.fg_muted)
painter.setFont(font)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
return rect.left()
class PaletteDelegate(QStyledItemDelegate):
"""Paints result rows: icon tile, highlighted title, subtitle and a right-hand hint."""
def __init__(self, controller: CommandPaletteController, parent: QObject | None = None):
super().__init__(parent)
self.controller = controller
self.tokens = ThemeTokens()
def has_header(self, index: QModelIndex) -> bool:
"""Whether a section header is drawn above ``index``."""
if not self.controller.sectioned:
return False
section = index.data(_role("section"))
if not section:
return False
if index.row() == 0:
return True
previous = index.siblingAtRow(index.row() - 1)
return previous.data(_role("section")) != section
def sizeHint(self, option: QStyleOptionViewItem, index: QModelIndex) -> QSize:
# pylint: disable=invalid-name
height = ROW_HEIGHT + (HEADER_HEIGHT if self.has_header(index) else 0)
return QSize(option.rect.width(), height)
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
tokens = self.tokens
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
rect = QRectF(option.rect)
if self.has_header(index):
header = QRectF(rect.left() + 18, rect.top(), rect.width() - 36, HEADER_HEIGHT)
painter.setPen(tokens.fg_subtle)
painter.setFont(_font(11, QFont.Weight.DemiBold))
painter.drawText(
header.adjusted(0, 8, 0, 0),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
str(index.data(_role("section"))).upper(),
)
rect.setTop(rect.top() + HEADER_HEIGHT)
current = index.row() == self.controller.currentIndex
hovered = bool(option.state & QStyle.StateFlag.State_MouseOver)
row = rect.adjusted(8, 1, -8, -1)
if current or hovered:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tokens.soft(tokens.primary, 0.16) if current else tokens.hover)
painter.drawRoundedRect(row, 8, 8)
# icon tile
tile = QRectF(row.left() + 8, row.center().y() - 15, 30, 30)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(tokens.soft(tokens.primary, 0.24) if current else tokens.track)
painter.drawRoundedRect(tile, 7, 7)
icon_color = tokens.primary if current else tokens.fg_muted
pixmap = _icon(str(index.data(_role("icon")) or "bolt"), icon_color.name(), 18)
painter.drawPixmap(QPointF(tile.center().x() - 9, tile.center().y() - 9), pixmap)
# right-hand side: shortcut keycaps, run hint on the current row, else the category
right = row.right() - 10
shortcut = str(index.data(_role("shortcut")) or "")
if current:
right = draw_keycap(painter, right, row.center().y(), "↵", tokens) - 6
painter.setFont(_font(12))
painter.setPen(tokens.fg_muted)
hint = str(index.data(_role("hint")) or "")
width = QFontMetrics(painter.font()).horizontalAdvance(hint)
painter.drawText(
QRectF(right - width, row.top(), width, row.height()),
Qt.AlignmentFlag.AlignVCenter,
hint,
)
right -= width + 12
elif shortcut:
right = draw_keycap(painter, right, row.center().y(), shortcut, tokens) - 10
elif not self.controller.sectioned:
painter.setFont(_font(11))
painter.setPen(tokens.fg_subtle)
category = str(index.data(_role("category")) or "")
width = QFontMetrics(painter.font()).horizontalAdvance(category)
painter.drawText(
QRectF(right - width, row.top(), width, row.height()),
Qt.AlignmentFlag.AlignVCenter,
category,
)
right -= width + 12
# title with matched characters, subtitle below
left = tile.right() + 12
available = max(20.0, right - left)
title = str(index.data(_role("title")) or "")
matched = set(index.data(_role("positions")) or [])
regular = _font(13)
bold = _font(13, QFont.Weight.Bold)
x = left
baseline = row.top() + 20
elided = QFontMetrics(regular).elidedText(
title, Qt.TextElideMode.ElideRight, int(available)
)
runs: list[tuple[bool, str]] = []
for i, char in enumerate(elided):
hit = i in matched and not (elided.endswith("…") and i == len(elided) - 1)
if runs and runs[-1][0] == hit:
runs[-1] = (hit, runs[-1][1] + char)
else:
runs.append((hit, char))
for hit, text in runs:
font = bold if hit else regular
painter.setFont(font)
painter.setPen(tokens.primary if hit else tokens.fg)
painter.drawText(QPointF(x, baseline), text)
x += QFontMetrics(font).horizontalAdvance(text)
subtitle = str(index.data(_role("subtitle")) or "")
if subtitle:
painter.setFont(_font(11))
painter.setPen(tokens.fg_subtle)
painter.drawText(
QRectF(left, row.top() + 24, available, 16),
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
QFontMetrics(painter.font()).elidedText(
subtitle, Qt.TextElideMode.ElideRight, int(available)
),
)
painter.restore()
class ScopeChip(QAbstractButton):
"""Checkable pill showing a scope and its query prefix, painted like the QML chip."""
def __init__(self, label: str, scope: str, parent: QWidget | None = None):
super().__init__(parent)
self.setText(label)
self.scope = scope
self.tokens = ThemeTokens()
self.setCheckable(True)
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
self.setFixedHeight(25)
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
bold = _font(12, QFont.Weight.DemiBold)
return QSize(QFontMetrics(bold).horizontalAdvance(self.text()) + 24, 25)
def paintEvent(self, _event): # pylint: disable=invalid-name
tokens = self.tokens
active = self.isChecked()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5)
if active:
fill = tokens.soft(tokens.primary, 0.18)
elif self.underMouse():
fill = tokens.hover
else:
fill = QColor(Qt.GlobalColor.transparent)
painter.setBrush(fill)
painter.setPen(tokens.soft(tokens.primary, 0.5) if active else tokens.border)
painter.drawRoundedRect(rect, rect.height() / 2, rect.height() / 2)
painter.setFont(_font(12, QFont.Weight.DemiBold if active else QFont.Weight.Normal))
painter.setPen(tokens.primary if active else tokens.fg_muted)
painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, self.text())
painter.end()
class PaletteCardWidget(QFrame):
"""Search field, scope chips, result list and key hints of the command palette.
Args:
controller(CommandPaletteController): Shared search state.
parent(QWidget | None): The hosting overlay.
"""
shadow_margin = 0
def __init__(self, controller: CommandPaletteController, parent: QWidget | None = None):
super().__init__(parent)
self.setObjectName("PaletteCard")
self.controller = controller
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# search row
search_bar = QWidget(self)
search_bar.setFixedHeight(54)
search_row = QHBoxLayout(search_bar)
search_row.setContentsMargins(18, 4, 14, 0)
search_row.setSpacing(10)
self.search_icon = QLabel(self)
self.search_icon.setFixedSize(20, 20)
self.input = QLineEdit(self)
self.input.setObjectName("PaletteInput")
self.input.setPlaceholderText("Search actions, widgets, devices and workspaces…")
self.input.setFrame(False)
self.input.installEventFilter(self)
self.input.textEdited.connect(controller.set_query)
self.esc_label = QLabel("esc", self)
self.esc_label.setObjectName("PaletteKey")
self.esc_label.setFixedHeight(20)
search_row.addWidget(self.search_icon)
search_row.addWidget(self.input, 1)
search_row.addWidget(self.esc_label)
layout.addWidget(search_bar)
# scope chips
chip_row = QHBoxLayout()
chip_row.setContentsMargins(14, 0, 14, 10)
chip_row.setSpacing(6)
self.chips = QButtonGroup(self)
self.chips.setExclusive(True)
for scope, label, prefix in SCOPES:
chip = ScopeChip(f"{label} {prefix}".strip(), scope, self)
chip.clicked.connect(lambda _=False, s=scope: self._pick_scope(s))
self.chips.addButton(chip)
chip_row.addWidget(chip)
chip_row.addStretch(1)
layout.addLayout(chip_row)
self.divider = QFrame(self)
self.divider.setFixedHeight(1)
layout.addWidget(self.divider)
# results / empty state
self.stack = QStackedWidget(self)
self.list = QListView(self)
self.list.setObjectName("PaletteList")
self.list.setModel(controller.model)
self.delegate = PaletteDelegate(controller, self.list)
self.list.setItemDelegate(self.delegate)
self.list.setFrameShape(QFrame.Shape.NoFrame)
self.list.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.list.setMouseTracking(True)
self.list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.list.setVerticalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel)
self.list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.list.setSpacing(0)
self.list.clicked.connect(lambda index: controller.execute(index.row()))
self.empty = QWidget(self)
empty_layout = QVBoxLayout(self.empty)
empty_layout.addStretch(1)
self.empty_icon = QLabel(self.empty)
self.empty_icon.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_title = QLabel(self.empty)
self.empty_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.empty_hint = QLabel(
"Type > for actions, + for widgets, @ for devices, # for workspaces", self.empty
)
self.empty_hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
for widget in (self.empty_icon, self.empty_title, self.empty_hint):
empty_layout.addWidget(widget)
empty_layout.addStretch(2)
self.stack.addWidget(self.list)
self.stack.addWidget(self.empty)
layout.addWidget(self.stack, 1)
# footer
self.footer = QFrame(self)
self.footer.setObjectName("PaletteFooter")
footer_row = QHBoxLayout(self.footer)
footer_row.setContentsMargins(16, 7, 16, 7)
footer_row.setSpacing(14)
self.footer_hints = QLabel(self.footer)
self.count_label = QLabel(self.footer)
footer_row.addWidget(self.footer_hints, 1)
footer_row.addWidget(self.count_label)
layout.addWidget(self.footer)
controller.queryChanged.connect(self._sync_query)
controller.scopeChanged.connect(self._sync_scope)
controller.resultsChanged.connect(self._sync_results)
controller.currentIndexChanged.connect(self._sync_current)
self.refresh_theme(ThemeTokens())
self._sync_scope()
self._sync_results()
# ------------------------------------------------------------------ host API
def focus_input(self) -> None:
"""Give the keyboard focus to the search field."""
self.input.setFocus(Qt.FocusReason.PopupFocusReason)
self.input.selectAll()
def refresh_theme(self, tokens: ThemeTokens) -> None:
"""Apply the theme tokens."""
self.delegate.tokens = tokens
for chip in self.chips.buttons():
chip.tokens = tokens
chip.update()
primary = tokens.primary.name()
self.setStyleSheet(f"""
#PaletteCard {{ background: {tokens.card.name()}; border: 1px solid
{tokens.border.name()}; border-radius: 12px; }}
#PaletteInput {{ background: transparent; border: none; color: {tokens.fg.name()};
font-size: 16px; selection-background-color: {primary}; }}
#PaletteKey {{ color: {tokens.fg_muted.name()}; background: {tokens.field.name()};
border: 1px solid {tokens.border.name()}; border-radius: 5px; padding: 0px 5px;
font-size: 11px; font-weight: 600; }}
#PaletteList {{ background: transparent; border: none; }}
#PaletteFooter {{ background: {tokens.field.name()}; border: none;
border-top: 1px solid {tokens.border.name()};
border-bottom-left-radius: 12px; border-bottom-right-radius: 12px; }}
#PaletteFooter QLabel {{ color: {tokens.fg_subtle.name()}; font-size: 11px;
background: transparent; }}
QScrollBar:vertical {{ background: transparent; width: 8px; margin: 4px 2px; }}
QScrollBar::handle:vertical {{ background: {tokens.track.name()};
border-radius: 2px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
""")
self.divider.setStyleSheet(f"background: {tokens.border.name()};")
self.search_icon.setPixmap(_icon("search", tokens.fg_muted.name(), 20))
self.empty_icon.setPixmap(_icon("search_off", tokens.fg_subtle.name(), 32))
self.empty_title.setStyleSheet(f"color: {tokens.fg_muted.name()}; font-size: 14px;")
self.empty_hint.setStyleSheet(f"color: {tokens.fg_subtle.name()}; font-size: 12px;")
key = f"<span style='color:{tokens.fg_muted.name()}; font-weight:600'>{{}}</span>"
self.footer_hints.setText(
"&nbsp;&nbsp;&nbsp;".join(
f"{key.format(k)}&nbsp;{label}"
for k, label in (
("↑↓", "navigate"),
("↵", "run"),
("tab", "scope"),
("esc", "close"),
)
)
)
self.list.viewport().update()
# ------------------------------------------------------------------ sync from controller
def _pick_scope(self, scope: str) -> None:
self.controller.set_scope(scope)
self.input.setFocus()
def _sync_query(self) -> None:
if self.input.text() != self.controller.query:
self.input.setText(self.controller.query)
def _sync_scope(self) -> None:
for chip in self.chips.buttons():
chip.setChecked(chip.scope == self.controller.scope)
def _sync_results(self) -> None:
count = self.controller.resultCount
self.stack.setCurrentWidget(self.list if count else self.empty)
self.empty_title.setText(self.controller.empty_text())
self.count_label.setText(f"{count} result{'s' if count != 1 else ''}" if count else "")
# row heights depend on the section headers
self.list.doItemsLayout()
self.list.scrollToTop()
def _sync_current(self) -> None:
row = self.controller.currentIndex
if row >= 0:
self.list.scrollTo(
self.controller.model.index(row, 0), QAbstractItemView.ScrollHint.EnsureVisible
)
self.list.viewport().update()
# ------------------------------------------------------------------ keyboard
def eventFilter(self, watched: QObject, event: QEvent) -> bool: # pylint: disable=invalid-name
if watched is not self.input or event.type() != QEvent.Type.KeyPress:
return super().eventFilter(watched, event)
key = event.key()
controller = self.controller
if key == Qt.Key.Key_Down:
controller.move(1)
elif key == Qt.Key.Key_Up:
controller.move(-1)
elif key == Qt.Key.Key_PageDown:
controller.move_page(PAGE_STEP)
elif key == Qt.Key.Key_PageUp:
controller.move_page(-PAGE_STEP)
elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
controller.execute()
elif key == Qt.Key.Key_Tab:
controller.cycle_scope(1)
elif key == Qt.Key.Key_Backtab:
controller.cycle_scope(-1)
elif key == Qt.Key.Key_Escape:
controller.request_dismiss()
elif key == Qt.Key.Key_Backspace and not self.input.text():
controller.clear_scope()
else:
return super().eventFilter(watched, event)
return True
@@ -0,0 +1,512 @@
"""Search model of the command palette, shared by the QML and QWidget views.
The palette searches a flat list of :class:`PaletteCommand` objects collected from command
sources (see :mod:`palette_sources`). :class:`CommandPaletteController` owns the query, the scope
filter, the ranked results and the keyboard selection; the two views only render its
:class:`~bec_widgets.utils.quick.DictListModel` and forward key presses to it, so both behave
exactly the same.
"""
from __future__ import annotations
import html
import time
from dataclasses import dataclass, field
from typing import Callable, Iterable
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, QObject, QTimer, Signal
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.quick.list_model import DictListModel
logger = bec_logger.logger
# category id -> (label, scope)
CATEGORIES: dict[str, tuple[str, str]] = {
"navigate": ("Go to", "actions"),
"actions": ("Actions", "actions"),
"widgets": ("Widgets", "widgets"),
"workspaces": ("Workspaces", "workspaces"),
"devices": ("Devices", "devices"),
}
# scope id, label, query prefix
SCOPES: list[tuple[str, str, str]] = [
("all", "All", ""),
("actions", "Actions", ">"),
("widgets", "Widgets", "+"),
("devices", "Devices", "@"),
("workspaces", "Workspaces", "#"),
]
_PREFIX_TO_SCOPE = {prefix: scope for scope, _label, prefix in SCOPES if prefix}
MAX_RESULTS = 60
EMPTY_QUERY_SECTION_LIMIT = 6
RECENT_LIMIT = 5
ROLES = [
"uid",
"title",
"titleRich",
"positions",
"subtitle",
"icon",
"category",
"section",
"shortcut",
"hint",
]
@dataclass
class PaletteCommand:
"""One entry of the command palette.
Args:
uid(str): Stable id, used to remember recently run commands.
title(str): Text that is searched and shown.
callback(Callable[[], object]): Runs the command.
category(str): One of :data:`CATEGORIES`.
subtitle(str): Secondary text, also searched with a lower weight.
icon(str): Material icon name.
keywords(str): Extra search terms that are not shown.
shortcut(str): Keyboard shortcut shown as a hint.
hint(str): Verb shown on the selected row, e.g. ``"Open"``.
"""
uid: str
title: str
callback: Callable[[], object]
category: str = "actions"
subtitle: str = ""
icon: str = "bolt"
keywords: str = ""
shortcut: str = ""
hint: str = "Run"
_title_l: str = field(default="", init=False, repr=False)
_extra_l: str = field(default="", init=False, repr=False)
def __post_init__(self):
self._title_l = self.title.lower()
self._extra_l = f"{self.subtitle} {self.keywords}".lower()
CommandSource = Callable[[], Iterable[PaletteCommand]]
def _is_boundary(text: str, index: int) -> bool:
if index == 0:
return True
prev, cur = text[index - 1], text[index]
if not prev.isalnum():
return True
return prev.islower() and cur.isupper()
def fuzzy_match(query: str, text: str) -> tuple[float, list[int]] | None:
"""Score how well ``query`` matches ``text``.
Contiguous substrings rank highest, then matches that start words (``"wf"`` in
``"Waveform"`` vs ``"Add Waveform"``), then any in-order subsequence. Case is ignored.
Args:
query(str): Search text without whitespace.
text(str): Candidate text.
Returns:
tuple[float, list[int]] | None: Score and matched character positions, or None if the
characters of ``query`` do not appear in order in ``text``.
"""
if not query:
return 0.0, []
q = query.lower()
t = text.lower()
start = t.find(q)
if start >= 0:
# prefer an occurrence at a word start, e.g. "form" in "Waveform Form"
pos = start
while pos >= 0 and not _is_boundary(text, pos):
pos = t.find(q, pos + 1)
boundary = pos >= 0
if boundary:
start = pos
score = 100.0 + 12 * len(q) + (40 if start == 0 else 0) + (25 if boundary else 0)
score -= 0.3 * len(t)
return score, list(range(start, start + len(q)))
# forward pass: earliest in-order match
positions: list[int] = []
cursor = 0
for char in q:
found = t.find(char, cursor)
if found < 0:
return None
positions.append(found)
cursor = found + 1
# backward pass: pull every match as far right as possible to keep it compact, then
# prefer word starts for each character
end = positions[-1]
for i in range(len(q) - 1, -1, -1):
found = t.rfind(q[i], 0, end + 1)
positions[i] = found
end = found - 1
for i, char in enumerate(q):
if _is_boundary(text, positions[i]):
continue
low = positions[i - 1] + 1 if i else 0
high = positions[i + 1] if i + 1 < len(q) else len(t)
for candidate in range(low, high):
if t[candidate] == char and _is_boundary(text, candidate):
positions[i] = candidate
break
score = 0.0
for i, pos in enumerate(positions):
score += 8
if _is_boundary(text, pos):
score += 14
if i and pos == positions[i - 1] + 1:
score += 10
elif i:
score -= min(pos - positions[i - 1] - 1, 8)
score -= 0.3 * len(t) + positions[0] * 0.5
return score, positions
def score_command(tokens: list[str], command: PaletteCommand) -> tuple[float, list[int]] | None:
"""Match all whitespace-separated ``tokens`` against a command.
Each token must fuzzy-match the title (highlighted) or appear verbatim in the subtitle and
keywords (scored lower, not highlighted).
"""
total = 0.0
positions: set[int] = set()
extra = command._extra_l # pylint: disable=protected-access
for token in tokens:
title_match = fuzzy_match(token, command.title)
if title_match is not None:
total += title_match[0]
positions.update(title_match[1])
continue
if token.lower() not in extra:
return None
total += 20 + 4 * len(token)
return total, sorted(positions)
def rich_title(title: str, positions: Iterable[int], color: str) -> str:
"""Return ``title`` as Qt rich text with the matched characters bold and coloured."""
matched = set(positions)
if not matched:
return html.escape(title)
parts = []
run: list[str] = []
run_matched = False
for i, char in enumerate(title):
is_match = i in matched
if run and is_match != run_matched:
parts.append((run_matched, "".join(run)))
run = []
run.append(char)
run_matched = is_match
if run:
parts.append((run_matched, "".join(run)))
return "".join(
f'<b><font color="{color}">{html.escape(text)}</font></b>' if hit else html.escape(text)
for hit, text in parts
)
class CommandPaletteController(QObject):
"""Query, scope, ranked results and selection of the command palette.
Args:
sources(Iterable[CommandSource]): Callables returning the commands; they are called
again on every :meth:`refresh`, i.e. every time the palette opens.
parent(QObject | None): Parent object.
"""
queryChanged = Signal()
scopeChanged = Signal()
currentIndexChanged = Signal()
resultsChanged = Signal()
accepted = Signal(str)
dismissRequested = Signal()
highlightChanged = Signal()
def __init__(self, sources: Iterable[CommandSource] = (), parent: QObject | None = None):
super().__init__(parent)
self._sources: list[CommandSource] = list(sources)
self._commands: list[PaletteCommand] = []
self._by_uid: dict[str, PaletteCommand] = {}
self._recent: list[str] = []
self._query = ""
self._scope = "all"
self._current = -1
self._results: list[PaletteCommand] = []
self._positions: dict[str, list[int]] = {}
self._sections: dict[str, str] = {}
self._highlight = "#3b82f6"
self._pending: PaletteCommand | None = None
self.last_search_ms = 0.0
self.model = DictListModel(ROLES, self)
# ------------------------------------------------------------------ sources
def add_source(self, source: CommandSource) -> None:
"""Register another command source."""
self._sources.append(source)
def refresh(self) -> None:
"""Re-collect the commands from all sources and re-run the search."""
commands: list[PaletteCommand] = []
for source in self._sources:
try:
commands.extend(source())
except Exception as exc: # pylint: disable=broad-except
logger.warning(f"Command palette source {source!r} failed: {exc}")
self._commands = commands
self._by_uid = {cmd.uid: cmd for cmd in commands}
self._search()
@property
def commands(self) -> list[PaletteCommand]:
"""All commands collected by the last :meth:`refresh`."""
return list(self._commands)
@property
def results(self) -> list[PaletteCommand]:
"""The commands currently listed, in display order."""
return list(self._results)
# ------------------------------------------------------------------ properties
def _get_query(self) -> str:
return self._query
def set_query(self, text: str) -> None:
"""Set the search text. A leading ``>``, ``+``, ``@`` or ``#`` switches the scope."""
text = text or ""
if text[:1] in _PREFIX_TO_SCOPE:
self._set_scope_silent(_PREFIX_TO_SCOPE[text[0]])
text = text[1:].lstrip()
if text == self._query:
self._search()
return
self._query = text
self.queryChanged.emit()
self._search()
def _get_scope(self) -> str:
return self._scope
def _set_scope_silent(self, scope: str) -> None:
if scope not in {s[0] for s in SCOPES} or scope == self._scope:
return
self._scope = scope
self.scopeChanged.emit()
def set_scope(self, scope: str) -> None:
"""Restrict the results to one scope, e.g. ``"devices"``; ``"all"`` searches everything."""
if scope == self._scope:
return
self._set_scope_silent(scope)
self._search()
def _get_current(self) -> int:
return self._current
def set_current_index(self, index: int) -> None:
"""Select a result row; clamped to the result range."""
index = max(-1, min(int(index), len(self._results) - 1))
if not self._results:
index = -1
elif index < 0:
index = 0
if index != self._current:
self._current = index
self.currentIndexChanged.emit()
def _get_highlight(self) -> str:
return self._highlight
def set_highlight_color(self, color: str) -> None:
"""Colour used for matched characters in ``titleRich``."""
if color == self._highlight:
return
self._highlight = color
self.highlightChanged.emit()
self._publish()
def _get_result_count(self) -> int:
return len(self._results)
def _get_sectioned(self) -> bool:
return not self._query
query = Property(str, _get_query, set_query, notify=queryChanged)
scope = Property(str, _get_scope, set_scope, notify=scopeChanged)
currentIndex = Property(int, _get_current, set_current_index, notify=currentIndexChanged)
resultCount = Property(int, _get_result_count, notify=resultsChanged)
sectioned = Property(bool, _get_sectioned, notify=resultsChanged)
scopes = Property(
"QVariantList",
lambda self: [{"id": s, "label": label, "prefix": prefix} for s, label, prefix in SCOPES],
constant=True,
)
emptyText = Property(str, lambda self: self.empty_text(), notify=resultsChanged)
# ------------------------------------------------------------------ keyboard
@SafeSlot(int)
def move(self, delta: int) -> None:
"""Move the selection by ``delta`` rows, wrapping around at the ends."""
count = len(self._results)
if not count:
return
self.set_current_index((self._current + delta) % count)
@SafeSlot(int)
def move_page(self, delta: int) -> None:
"""Move the selection by ``delta`` rows without wrapping."""
self.set_current_index(max(0, self._current + delta))
@SafeSlot(int)
def cycle_scope(self, step: int = 1) -> None:
"""Switch to the next (``step=1``) or previous (``step=-1``) scope."""
ids = [s[0] for s in SCOPES]
self.set_scope(ids[(ids.index(self._scope) + step) % len(ids)])
@SafeSlot()
def request_dismiss(self) -> None:
"""Ask the hosting overlay to close the palette without running anything."""
self.dismissRequested.emit()
@SafeSlot()
def clear_scope(self) -> None:
"""Backspace in an empty search field drops the scope filter."""
if self._scope != "all":
self.set_scope("all")
def empty_text(self) -> str:
"""Message shown when there are no results."""
if self._results:
return ""
label = dict((s[0], s[1]) for s in SCOPES)[self._scope].lower()
if self._query:
where = "" if self._scope == "all" else f" in {label}"
return f"No matches for “{self._query}”{where}"
return f"No {label} available"
@SafeSlot(int)
def execute(self, index: int = -1) -> None:
"""Run the result at ``index`` (the selected one by default).
:attr:`accepted` is emitted first so the view can close; the command itself runs on the
next event-loop turn.
"""
if index < 0:
index = self._current
if not 0 <= index < len(self._results):
return
command = self._results[index]
if command.uid in self._recent:
self._recent.remove(command.uid)
self._recent.insert(0, command.uid)
del self._recent[20:]
self._pending = command
self.accepted.emit(command.uid)
QTimer.singleShot(0, self._run_pending)
@SafeSlot(popup_error=True)
def _run_pending(self) -> None:
command, self._pending = self._pending, None
if command is not None:
command.callback()
def reset(self) -> None:
"""Clear the query and scope, as done whenever the palette opens."""
self._query = ""
self._scope = "all"
self.queryChanged.emit()
self.scopeChanged.emit()
# ------------------------------------------------------------------ search
def _in_scope(self, command: PaletteCommand) -> bool:
if self._scope == "all":
return True
return CATEGORIES.get(command.category, ("", "actions"))[1] == self._scope
def _search(self) -> None:
started = time.perf_counter()
candidates = [cmd for cmd in self._commands if self._in_scope(cmd)]
positions: dict[str, list[int]] = {}
sections: dict[str, str] = {}
if not self._query:
results = self._browse(candidates, sections)
else:
tokens = self._query.split()
ranked = []
recent_rank = {uid: i for i, uid in enumerate(self._recent)}
for order, cmd in enumerate(candidates):
match = score_command(tokens, cmd)
if match is None:
continue
score, pos = match
if cmd.uid in recent_rank:
score += 20 - 2 * recent_rank[cmd.uid]
ranked.append((-score, order, cmd))
positions[cmd.uid] = pos
ranked.sort(key=lambda item: (item[0], item[1]))
results = [cmd for _score, _order, cmd in ranked[:MAX_RESULTS]]
self._results = results
self._positions = positions
self._sections = sections
self._publish()
self._current = -1
self.set_current_index(0)
self.last_search_ms = (time.perf_counter() - started) * 1000
def _browse(self, candidates: list[PaletteCommand], sections: dict[str, str]):
"""Results for an empty query: recent commands, then a few of each category."""
results: list[PaletteCommand] = []
seen: set[str] = set()
if self._scope == "all":
in_scope = {cmd.uid for cmd in candidates}
for uid in self._recent:
if uid in in_scope and len(results) < RECENT_LIMIT:
results.append(self._by_uid[uid])
sections[uid] = "Recent"
seen.add(uid)
limit = EMPTY_QUERY_SECTION_LIMIT if self._scope == "all" else MAX_RESULTS
for category, (label, _scope) in CATEGORIES.items():
count = 0
for cmd in candidates:
if cmd.category != category or cmd.uid in seen:
continue
if count >= limit:
break
results.append(cmd)
sections[cmd.uid] = label
seen.add(cmd.uid)
count += 1
return results
def _publish(self) -> None:
rows = []
for cmd in self._results:
pos = self._positions.get(cmd.uid, [])
rows.append(
{
"uid": cmd.uid,
"title": cmd.title,
"titleRich": rich_title(cmd.title, pos, self._highlight),
"positions": pos,
"subtitle": cmd.subtitle,
"icon": cmd.icon,
"category": CATEGORIES.get(cmd.category, (cmd.category, ""))[0],
"section": self._sections.get(cmd.uid, ""),
"shortcut": cmd.shortcut,
"hint": cmd.hint,
}
)
self.model.set_items(rows)
self.resultsChanged.emit()
@@ -0,0 +1,264 @@
"""Command sources of the BEC main app's command palette.
A source is a callable returning :class:`PaletteCommand` objects. Sources run every time the
palette opens, so they always reflect the current menus, workspaces and devices. Views of the
main app contribute their own commands by implementing ``palette_commands()``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Callable, Iterable
from bec_lib.device import Positioner
from qtpy.QtGui import QAction, QKeySequence
from qtpy.QtWidgets import QMenu, QMenuBar
from bec_widgets.utils.name_utils import pascal_to_title
from bec_widgets.widgets.utility.command_palette.palette_core import PaletteCommand
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.applications.main_app import BECMainApp
from bec_widgets.widgets.containers.dock_area.dock_area import BECDockArea
def _clean(text: str) -> str:
return text.replace("&&", "\x00").replace("&", "").replace("\x00", "&").strip()
def _shortcut(action: QAction) -> str:
shortcut = action.shortcut()
if shortcut.isEmpty():
return ""
return shortcut.toString(QKeySequence.SequenceFormat.NativeText)
def menu_commands(menu_bar: QMenuBar, skip: Iterable[QAction] = ()) -> list[PaletteCommand]:
"""Turn every enabled, visible menu bar action into a command.
Args:
menu_bar(QMenuBar): Menu bar to walk, including sub-menus.
skip(Iterable[QAction]): Actions to leave out, e.g. the one opening the palette.
Returns:
list[PaletteCommand]: One command per action, titled ``"<Menu> › <Action>"``.
"""
skipped = set(skip)
commands: list[PaletteCommand] = []
def walk(menu: QMenu, path: list[str]) -> None:
for action in menu.actions():
if action.isSeparator() or not action.isVisible() or action in skipped:
continue
text = _clean(action.text())
if action.menu() is not None:
walk(action.menu(), [*path, text])
continue
if not text or not action.isEnabled():
continue
state = ""
if action.isCheckable():
state = " · on" if action.isChecked() else " · off"
commands.append(
PaletteCommand(
uid=f"menu:{'/'.join(path)}/{text}",
title=text,
subtitle=f"{' › '.join(path)} menu{state}",
callback=action.trigger,
category="actions",
icon="toggle_on" if action.isCheckable() else "menu",
shortcut=_shortcut(action),
keywords=" ".join(path),
)
)
for top_action in menu_bar.actions():
if top_action.menu() is not None and top_action.isVisible():
walk(top_action.menu(), [_clean(top_action.text())])
return commands
def main_app_commands(app: BECMainApp) -> list[PaletteCommand]:
"""Navigation between the app's views, app-level actions and the views' own commands."""
commands: list[PaletteCommand] = []
for view_id in app._view_index: # pylint: disable=protected-access
view = app.stack.widget(app._view_index[view_id]) # pylint: disable=protected-access
item = app.sidebar.components.get(view_id)
title = getattr(view, "view_title", None) or view_id
commands.append(
PaletteCommand(
uid=f"view:{view_id}",
title=f"Go to {title}",
subtitle="View",
callback=lambda vid=view_id: app.set_current(vid),
category="navigate",
icon=getattr(item, "_icon_name", None) or "view_quilt",
keywords="open switch show view page",
hint="Open",
)
)
commands.append(
PaletteCommand(
uid="app:toggle_sidebar",
title="Toggle Sidebar",
subtitle="Expand or collapse the navigation sidebar",
callback=app.sidebar.toggle.click,
category="actions",
icon="side_navigation",
keywords="navigation menu collapse expand",
)
)
theme = getattr(getattr(app, "app", None), "theme", None)
current = str(getattr(theme, "theme", "dark")).lower()
target = "light" if "dark" in current else "dark"
commands.append(
PaletteCommand(
uid="app:toggle_theme",
title=f"Switch to {target.title()} Theme",
subtitle="Appearance",
callback=lambda: app.change_theme(target),
category="actions",
icon="light_mode" if target == "light" else "dark_mode",
keywords="theme dark light mode colour color appearance",
)
)
for view_id in app._view_index: # pylint: disable=protected-access
view = app.stack.widget(app._view_index[view_id]) # pylint: disable=protected-access
for owner in (view, getattr(view, "content", None)):
provider: Callable[[], Iterable[PaletteCommand]] | None = getattr(
owner, "palette_commands", None
)
if callable(provider):
commands.extend(provider())
break
return commands
def _config_value(device, key: str, default=None):
config = getattr(device, "_config", None)
if isinstance(config, dict):
return config.get(key, default)
return getattr(config, key, default)
def dock_area_commands(
dock_area: BECDockArea, activate: Callable[[], None]
) -> list[PaletteCommand]:
"""Commands that add widgets to the dock area and manage its workspaces.
Args:
dock_area(BECDockArea): The dock area the commands act on.
activate(Callable[[], None]): Shows the view holding the dock area before acting.
"""
commands: list[PaletteCommand] = []
def add_widget(widget_type: str) -> None:
activate()
dock_area.new(widget=widget_type)
for group, mapping in dock_area.widget_catalog().items():
for icon, label, widget_type in mapping.values():
commands.append(
PaletteCommand(
uid=f"widget:{widget_type}",
title=label.strip(),
subtitle=f"Add to dock area · {group}",
callback=lambda t=widget_type: add_widget(t),
category="widgets",
icon=icon,
keywords=f"add new dock {widget_type} {pascal_to_title(widget_type)}",
hint="Add",
)
)
def load(name: str) -> None:
activate()
dock_area.load_profile(name)
current = getattr(dock_area, "_current_profile_name", None)
for name in dock_area.list_profiles():
commands.append(
PaletteCommand(
uid=f"workspace:{name}",
title=name,
subtitle="Current workspace" if name == current else "Open workspace",
callback=lambda n=name: load(n),
category="workspaces",
icon="space_dashboard",
keywords="workspace profile layout load open",
hint="Open",
)
)
locked = dock_area.workspace_is_locked
workspace_actions = [
("save", "Save Workspace As…", "save", dock_area.save_profile_dialog, "store profile"),
("manage", "Manage Workspaces", "tune", dock_area.show_workspace_manager, "profiles"),
(
"lock",
"Unlock Workspace" if locked else "Lock Workspace",
"lock_open" if locked else "lock",
lambda: setattr(dock_area, "workspace_is_locked", not locked),
"freeze edit layout",
),
]
for key, title, icon, func, keywords in workspace_actions:
commands.append(
PaletteCommand(
uid=f"workspace_action:{key}",
title=title,
subtitle="Workspace",
callback=lambda f=func: (activate(), f()),
category="actions",
icon=icon,
keywords=f"workspace {keywords}",
)
)
return commands
def device_commands(dock_area: BECDockArea, activate: Callable[[], None]) -> list[PaletteCommand]:
"""One command per device: positioners open a device box, others a waveform plot."""
client = getattr(dock_area, "client", None)
manager = getattr(client, "device_manager", None)
devices = getattr(manager, "devices", None)
if not devices:
return []
def open_positioner(name: str) -> None:
activate()
dock_area.new(widget="PositionerBox", device=name)
def plot(name: str) -> None:
activate()
waveform = dock_area.new(widget="Waveform")
waveform.plot(device_y=name)
commands = []
for name in sorted(devices.keys()):
device = devices[name]
positioner = isinstance(device, Positioner)
device_class = str(_config_value(device, "deviceClass", "") or "")
short_class = device_class.rsplit(".", 1)[-1]
priority = str(_config_value(device, "readoutPriority", "") or "")
enabled = bool(getattr(device, "enabled", True))
tags = _config_value(device, "deviceTags", None) or ()
description = str(_config_value(device, "description", "") or "")
details = [short_class or ("Positioner" if positioner else "Device")]
if priority:
details.append(priority)
if not enabled:
details.append("disabled")
commands.append(
PaletteCommand(
uid=f"device:{name}",
title=name,
subtitle=" · ".join(details),
callback=(
(lambda n=name: open_positioner(n)) if positioner else (lambda n=name: plot(n))
),
category="devices",
icon="open_with" if positioner else "sensors",
keywords=f"{device_class} {' '.join(map(str, tags))} {description}",
hint="Move" if positioner else "Plot",
)
)
return commands
@@ -0,0 +1,381 @@
import QtQuick
import QtQuick.Controls.Basic
import BecUi
// Command palette card. Twin of command_palette_qwidget.PaletteCardWidget; all state lives in
// the Python CommandPaletteController passed in as `backend`.
Item {
id: root
required property var backend
required property var paletteModel
property int margin: 24
readonly property int rowHeight: 46
readonly property int headerHeight: 28
// called from Python every time the palette opens
function opened() {
field.forceActiveFocus()
field.selectAll()
}
component KeyCap: Rectangle {
property alias text: capLabel.text
implicitWidth: capLabel.implicitWidth + 12
implicitHeight: 20
radius: 5
color: theme.field
border.color: theme.border
Text {
id: capLabel
anchors.centerIn: parent
color: theme.fgMuted
font.pixelSize: 11
font.weight: Font.DemiBold
}
}
// clicks next to the card (on the shadow margin) close the palette like the dimmed window
MouseArea {
anchors.fill: parent
onPressed: root.backend.request_dismiss()
}
// soft layered shadow; MultiEffect is unavailable on the software scene graph
Repeater {
model: 6
Rectangle {
required property int index
x: root.margin - index * 3
y: root.margin + 10 - index * 2
width: card.width + index * 6
height: card.height + index * 5
radius: 12 + index * 3
color: "black"
opacity: (theme.dark ? 0.11 : 0.045) * (1 - index / 7)
}
}
Rectangle {
id: card
x: root.margin
y: root.margin
width: root.width - 2 * root.margin
height: root.height - 2 * root.margin
radius: 12
color: theme.card
border.color: theme.border
clip: true
// swallow clicks so they do not reach the dismiss area
MouseArea { anchors.fill: parent }
Column {
id: header
width: parent.width
Item {
width: parent.width
height: 54
Icon {
id: searchIcon
name: "search"
size: 20
color: theme.fgMuted
anchors.left: parent.left
anchors.leftMargin: 18
anchors.verticalCenter: parent.verticalCenter
anchors.verticalCenterOffset: 2
}
TextField {
id: field
anchors.left: searchIcon.right
anchors.leftMargin: 10
anchors.right: escCap.left
anchors.rightMargin: 10
anchors.verticalCenter: searchIcon.verticalCenter
background: null
padding: 0
color: theme.fg
placeholderText: "Search actions, widgets, devices and workspaces…"
placeholderTextColor: theme.fgSubtle
selectionColor: theme.primary
selectedTextColor: theme.onPrimary
font.pixelSize: 16
selectByMouse: true
onTextEdited: root.backend.query = text
Connections {
target: root.backend
function onQueryChanged() {
if (field.text !== root.backend.query)
field.text = root.backend.query
}
}
Keys.onPressed: (event) => {
const b = root.backend
switch (event.key) {
case Qt.Key_Down: b.move(1); break
case Qt.Key_Up: b.move(-1); break
case Qt.Key_PageDown: b.move_page(8); break
case Qt.Key_PageUp: b.move_page(-8); break
case Qt.Key_Return:
case Qt.Key_Enter: b.execute(-1); break
case Qt.Key_Tab: b.cycle_scope(1); break
case Qt.Key_Backtab: b.cycle_scope(-1); break
case Qt.Key_Escape: b.request_dismiss(); break
case Qt.Key_Backspace:
if (field.text !== "") return
b.clear_scope()
break
default: return
}
event.accepted = true
}
}
KeyCap {
id: escCap
text: "esc"
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: searchIcon.verticalCenter
}
}
Row {
leftPadding: 14
bottomPadding: 10
spacing: 6
Repeater {
model: root.backend.scopes
Rectangle {
required property var modelData
readonly property bool active: root.backend.scope === modelData.id
height: 25
width: chipLabel.implicitWidth + 24
radius: 12.5
color: active ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.18))
: chipMouse.containsMouse ? theme.hover : "transparent"
border.color: active ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.5))
: theme.border
Text {
id: chipLabel
anchors.centerIn: parent
text: (modelData.label + " " + modelData.prefix).trim()
color: parent.active ? theme.primary : theme.fgMuted
font.pixelSize: 12
font.weight: parent.active ? Font.DemiBold : Font.Normal
}
MouseArea {
id: chipMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.backend.scope = modelData.id
field.forceActiveFocus()
}
}
}
}
}
Rectangle { width: parent.width; height: 1; color: theme.border }
}
ListView {
id: list
anchors.top: header.bottom
anchors.bottom: footer.top
anchors.left: parent.left
anchors.right: parent.right
clip: true
model: root.paletteModel
currentIndex: root.backend.currentIndex
boundsBehavior: Flickable.StopAtBounds
highlightMoveDuration: 0
visible: count > 0
onCurrentIndexChanged: if (currentIndex >= 0) positionViewAtIndex(currentIndex, ListView.Contain)
onCountChanged: positionViewAtBeginning()
ScrollBar.vertical: ScrollBar {
width: 8
visible: list.contentHeight > list.height
contentItem: Rectangle { implicitWidth: 4; radius: 2; color: theme.track }
}
// rows carry an empty section while searching; changing section.property at
// runtime instead crashes the ListView
section.property: "section"
section.delegate: Item {
required property string section
width: ListView.view.width
height: section === "" ? 0 : root.headerHeight
visible: section !== ""
Text {
x: 18
y: 8
height: parent.height - 8
verticalAlignment: Text.AlignVCenter
text: parent.section.toUpperCase()
color: theme.fgSubtle
font.pixelSize: 11
font.weight: Font.DemiBold
}
}
delegate: Item {
id: row
required property int index
required property string titleRich
required property string subtitle
required property string icon
required property string category
required property string shortcut
required property string hint
readonly property bool current: ListView.isCurrentItem
width: ListView.view.width
height: root.rowHeight
Rectangle {
anchors.fill: parent
anchors.leftMargin: 8
anchors.rightMargin: 8
anchors.topMargin: 1
anchors.bottomMargin: 1
radius: 8
color: row.current ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.16))
: rowMouse.containsMouse ? theme.hover : "transparent"
}
Rectangle {
id: tile
x: 16
anchors.verticalCenter: parent.verticalCenter
width: 30
height: 30
radius: 7
color: row.current ? Qt.tint(theme.card, Qt.alpha(theme.primary, 0.24)) : theme.track
Icon {
anchors.centerIn: parent
name: row.icon
size: 18
color: row.current ? theme.primary : theme.fgMuted
}
}
Column {
anchors.left: tile.right
anchors.leftMargin: 12
anchors.right: trailing.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Text {
width: parent.width
text: row.titleRich
textFormat: Text.StyledText
color: theme.fg
font.pixelSize: 13
elide: Text.ElideRight
}
Text {
width: parent.width
visible: row.subtitle !== ""
text: row.subtitle
color: theme.fgSubtle
font.pixelSize: 11
elide: Text.ElideRight
}
}
Row {
id: trailing
anchors.right: parent.right
anchors.rightMargin: 18
anchors.verticalCenter: parent.verticalCenter
spacing: 6
Text {
visible: row.current
anchors.verticalCenter: parent.verticalCenter
text: row.hint
color: theme.fgMuted
font.pixelSize: 12
}
KeyCap { visible: row.current; text: "↵" }
KeyCap { visible: !row.current && row.shortcut !== ""; text: row.shortcut }
Text {
visible: !row.current && row.shortcut === "" && !root.backend.sectioned
anchors.verticalCenter: parent.verticalCenter
text: row.category
color: theme.fgSubtle
font.pixelSize: 11
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.backend.execute(row.index)
}
}
}
Column {
anchors.centerIn: list
anchors.verticalCenterOffset: -20
visible: list.count === 0
spacing: 8
Icon { anchors.horizontalCenter: parent.horizontalCenter; name: "search_off"; size: 32; color: theme.fgSubtle }
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.backend.emptyText
color: theme.fgMuted
font.pixelSize: 14
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "Type > for actions, + for widgets, @ for devices, # for workspaces"
color: theme.fgSubtle
font.pixelSize: 12
}
}
Rectangle {
id: footer
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: 1
height: 31
color: theme.field
bottomLeftRadius: 11
bottomRightRadius: 11
Rectangle { width: parent.width; height: 1; color: theme.border }
Row {
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
spacing: 14
Repeater {
model: [["↑↓", "navigate"], ["↵", "run"], ["tab", "scope"], ["esc", "close"]]
Text {
required property var modelData
textFormat: Text.StyledText
text: "<b><font color='" + theme.fgMuted + "'>" + modelData[0] + "</font></b> " + modelData[1]
color: theme.fgSubtle
font.pixelSize: 11
}
}
}
Text {
anchors.right: parent.right
anchors.rightMargin: 16
anchors.verticalCenter: parent.verticalCenter
visible: root.backend.resultCount > 0
text: root.backend.resultCount + (root.backend.resultCount === 1 ? " result" : " results")
color: theme.fgSubtle
font.pixelSize: 11
}
}
}
}
+351
View File
@@ -0,0 +1,351 @@
from unittest import mock
import pytest
from qtpy.QtCore import QPoint, Qt
from qtpy.QtWidgets import QMainWindow, QWidget
from bec_widgets.applications.main_app import BECMainApp
from bec_widgets.tests.client_mocks import mocked_client
from bec_widgets.widgets.utility.command_palette.command_palette import (
CommandPalette,
default_flavour,
)
from bec_widgets.widgets.utility.command_palette.palette_core import (
CommandPaletteController,
PaletteCommand,
fuzzy_match,
rich_title,
)
def _commands(calls: list | None = None):
calls = calls if calls is not None else []
def cmd(uid, title, category, **kwargs):
return PaletteCommand(
uid=uid, title=title, category=category, callback=lambda: calls.append(uid), **kwargs
)
return [
cmd("view:docks", "Go to Dock Area", "navigate"),
cmd("app:theme", "Switch to Light Theme", "actions", keywords="appearance"),
cmd("widget:Waveform", "Waveform", "widgets"),
cmd("widget:MultiWaveform", "Multi Waveform", "widgets"),
cmd("widget:ScanControl", "Scan Control", "widgets"),
cmd("device:samx", "samx", "devices", subtitle="SimPositioner"),
cmd("device:bpm4i", "bpm4i", "devices", subtitle="SimMonitor"),
cmd("workspace:alignment", "alignment", "workspaces"),
]
@pytest.fixture
def controller(qtbot):
calls = []
ctrl = CommandPaletteController([lambda: _commands(calls)])
ctrl.calls = calls
ctrl.refresh()
yield ctrl
ctrl.deleteLater()
# ---------------------------------------------------------------- matching
def test_fuzzy_match_prefers_substring_and_word_starts():
exact = fuzzy_match("wave", "Waveform")
inner = fuzzy_match("wave", "Multi Waveform")
scattered = fuzzy_match("wf", "Waveform")
assert exact[1] == [0, 1, 2, 3]
assert inner[1] == [6, 7, 8, 9]
assert exact[0] > inner[0] > scattered[0]
assert fuzzy_match("sc", "Scan Control")[1] == [0, 1]
assert fuzzy_match("scc", "Scan Control")[1] == [0, 1, 5]
assert fuzzy_match("zz", "Waveform") is None
assert fuzzy_match("", "anything") == (0.0, [])
def test_rich_title_highlights_and_escapes():
text = rich_title("a<b", [0, 2], "#ff0000")
assert text == (
'<b><font color="#ff0000">a</font></b>&lt;<b><font color="#ff0000">b</font></b>'
)
assert rich_title("plain", [], "#000") == "plain"
# ---------------------------------------------------------------- controller
def test_empty_query_groups_by_category(controller):
sections = [row["section"] for row in controller.model.items]
assert sections[0] == "Go to"
assert "Widgets" in sections and "Devices" in sections and "Workspaces" in sections
assert controller.sectioned
assert controller.currentIndex == 0
def test_query_ranks_best_match_first(controller):
controller.set_query("wave")
titles = [cmd.title for cmd in controller.results]
assert titles[:2] == ["Waveform", "Multi Waveform"]
assert not controller.sectioned
assert controller.model.items[0]["positions"] == [0, 1, 2, 3]
def test_multi_token_and_keyword_search(controller):
controller.set_query("multi wave")
assert [cmd.uid for cmd in controller.results] == ["widget:MultiWaveform"]
controller.set_query("appearance")
assert [cmd.uid for cmd in controller.results] == ["app:theme"]
@pytest.mark.parametrize(
"text, scope, uids",
[
("@sam", "devices", ["device:samx"]),
("+scan", "widgets", ["widget:ScanControl"]),
("#", "workspaces", ["workspace:alignment"]),
(">dock", "actions", ["view:docks"]),
],
)
def test_prefix_selects_scope(controller, text, scope, uids):
controller.set_query(text)
assert controller.scope == scope
assert [cmd.uid for cmd in controller.results] == uids
def test_scope_cycling_and_clearing(controller):
controller.cycle_scope(1)
assert controller.scope == "actions"
assert {cmd.category for cmd in controller.results} == {"navigate", "actions"}
controller.cycle_scope(-1)
controller.cycle_scope(-1)
assert controller.scope == "workspaces"
controller.clear_scope()
assert controller.scope == "all"
def test_move_wraps_and_page_clamps(controller):
controller.set_query("wave")
assert controller.resultCount == 2
controller.move(1)
assert controller.currentIndex == 1
controller.move(1)
assert controller.currentIndex == 0
controller.move(-1)
assert controller.currentIndex == 1
controller.move_page(-8)
assert controller.currentIndex == 0
def test_empty_text(controller):
controller.set_query("@xyzzy")
assert controller.resultCount == 0
assert controller.currentIndex == -1
assert controller.empty_text() == "No matches for “xyzzy” in devices"
def test_execute_runs_command_and_remembers_it(qtbot, controller):
accepted = []
controller.accepted.connect(accepted.append)
controller.set_query("bpm")
controller.execute()
assert accepted == ["device:bpm4i"]
qtbot.waitUntil(lambda: controller.calls == ["device:bpm4i"], timeout=1000)
controller.reset()
controller.refresh()
first = controller.model.items[0]
assert (first["uid"], first["section"]) == ("device:bpm4i", "Recent")
# recently used commands win ties
controller.set_query("s")
assert controller.results[0].uid != "device:bpm4i"
controller.execute(-1)
controller.set_query("b")
assert controller.results[0].uid == "device:bpm4i"
def test_failing_source_is_skipped(qtbot):
def broken():
raise RuntimeError("boom")
ctrl = CommandPaletteController([broken, lambda: _commands()])
ctrl.refresh()
assert len(ctrl.commands) == len(_commands())
ctrl.deleteLater()
def test_default_flavour(monkeypatch):
monkeypatch.delenv("BEC_COMMAND_PALETTE", raising=False)
assert default_flavour() == "qwidget"
monkeypatch.setenv("BEC_COMMAND_PALETTE", "QML")
assert default_flavour() == "qml"
# ---------------------------------------------------------------- overlay, both renderers
@pytest.fixture(params=["qwidget", "qml"])
def palette_window(qtbot, request):
window = QMainWindow()
window.setCentralWidget(QWidget())
window.resize(900, 640)
calls = []
palette = CommandPalette(window, sources=[lambda: _commands(calls)], flavour=request.param)
palette.install_shortcuts()
palette.calls = calls
qtbot.addWidget(window)
window.show()
qtbot.waitExposed(window)
yield window, palette
palette.cleanup()
def _type(qtbot, palette, text):
if palette.flavour == "qml":
palette.card.view.setFocus()
qtbot.keyClicks(palette.card.view, text)
else:
qtbot.keyClicks(palette.card.input, text)
def _key(qtbot, palette, key):
target = palette.card.view if palette.flavour == "qml" else palette.card.input
qtbot.keyClick(target, key)
def test_overlay_opens_with_shortcut_and_runs_command(qtbot, palette_window):
window, palette = palette_window
window.activateWindow()
window.centralWidget().setFocus()
palette.toggle()
assert palette.is_open()
assert palette.geometry() == window.rect()
card = palette.card_geometry()
assert card.width() == 640 and card.top() >= 24
_type(qtbot, palette, "wave")
qtbot.waitUntil(lambda: palette.controller.query == "wave", timeout=2000)
_key(qtbot, palette, Qt.Key.Key_Down)
assert palette.controller.currentIndex == 1
_key(qtbot, palette, Qt.Key.Key_Return)
assert not palette.is_open()
qtbot.waitUntil(lambda: palette.calls == ["widget:MultiWaveform"], timeout=1000)
def test_overlay_tab_and_escape(qtbot, palette_window):
_window, palette = palette_window
palette.open_palette()
_key(qtbot, palette, Qt.Key.Key_Tab)
assert palette.controller.scope == "actions"
_key(qtbot, palette, Qt.Key.Key_Backspace)
assert palette.controller.scope == "all"
_key(qtbot, palette, Qt.Key.Key_Escape)
assert not palette.is_open()
def test_overlay_click_outside_closes(qtbot, palette_window):
_window, palette = palette_window
palette.open_palette("@")
assert palette.controller.scope == "devices"
qtbot.mouseClick(palette, Qt.MouseButton.LeftButton, pos=QPoint(5, 5))
assert not palette.is_open()
# reopening starts fresh
palette.open_palette()
assert palette.controller.scope == "all" and palette.controller.query == ""
def test_qwidget_card_follows_controller(qtbot, palette_window):
_window, palette = palette_window
if palette.flavour != "qwidget":
pytest.skip("QWidget card only")
palette.open_palette()
card = palette.card
palette.controller.set_query(">")
assert card.input.text() == ""
assert [c.isChecked() for c in card.chips.buttons()][1]
palette.controller.set_query("nothing-matches")
assert card.stack.currentWidget() is card.empty
assert "nothing-matches" in card.empty_title.text()
assert card.count_label.text() == ""
def test_qml_card_loads(qtbot, palette_window):
_window, palette = palette_window
if palette.flavour != "qml":
pytest.skip("QML card only")
palette.open_palette()
assert palette.card.view.errors() == []
assert palette.card.view.rootObject() is not None
# ---------------------------------------------------------------- main app integration
@pytest.fixture
def main_app(qtbot, mocked_client):
app = BECMainApp(client=mocked_client, anim_duration=1, show_examples=False)
qtbot.addWidget(app)
app.show()
qtbot.waitExposed(app)
yield app
def _by_uid(app):
app.command_palette.controller.refresh()
return {cmd.uid: cmd for cmd in app.command_palette.controller.commands}
def test_main_app_palette_menu_entry(main_app):
action = main_app._command_palette_action
assert [s.toString() for s in action.shortcuts()] == ["Ctrl+K", "Ctrl+Shift+P"]
view_action = next(a for a in main_app.menuBar().actions() if a.text() == "View")
assert action in view_action.menu().actions()
action.trigger()
assert main_app.command_palette.is_open()
def test_main_app_commands(main_app):
commands = _by_uid(main_app)
assert "view:DM" in commands
assert "widget:Waveform" in commands and "widget:ScanControl" in commands
assert "device:samx" in commands and commands["device:samx"].hint == "Move"
assert commands["device:bpm4i"].hint == "Plot"
assert "menu:View/Dark Theme" in commands
assert "workspace_action:save" in commands
# the palette does not list itself
assert not any("Command Palette" in cmd.title for cmd in commands.values())
def test_main_app_navigation_command(qtbot, main_app):
_by_uid(main_app)["view:DM"].callback()
assert main_app.stack.currentIndex() == main_app._view_index["DM"]
def test_main_app_widget_and_device_commands(qtbot, main_app):
dock_area = main_app.dock_area.dock_area
main_app.set_current("DM")
commands = _by_uid(main_app)
commands["widget:RingProgressBar"].callback()
assert main_app.stack.currentIndex() == main_app._view_index["Docks"]
assert any(type(w).__name__ == "RingProgressBar" for w in dock_area.widget_list())
commands["device:samx"].callback()
boxes = [w for w in dock_area.widget_list() if type(w).__name__ == "PositionerBox"]
assert boxes and boxes[0].device == "samx"
with mock.patch("bec_widgets.widgets.plots.waveform.waveform.Waveform.plot") as plot:
commands["device:bpm4i"].callback()
plot.assert_called_once_with(device_y="bpm4i")
def test_main_app_workspace_commands(qtbot, main_app):
dock_area = main_app.dock_area.dock_area
with mock.patch.object(dock_area, "list_profiles", return_value=["alignment"]):
commands = _by_uid(main_app)
with mock.patch.object(dock_area, "load_profile") as load:
commands["workspace:alignment"].callback()
load.assert_called_once_with("alignment")
locked = dock_area.workspace_is_locked
commands["workspace_action:lock"].callback()
assert dock_area.workspace_is_locked is (not locked)