From 1fe19099a142de26cd9f3d9aaa6bdcd6f02a2def Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:37:29 +0000 Subject: [PATCH] feat(main_app): add Ctrl+K command palette with QWidget and QML views Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01QVD58dnQCRQSxSJLj5enEZ --- bec_widgets/applications/main_app.py | 42 ++ .../views/dock_area_view/dock_area_view.py | 13 + bec_widgets/utils/quick/__init__.py | 26 + bec_widgets/utils/quick/host.py | 233 ++++++++ bec_widgets/utils/quick/list_model.py | 83 +++ bec_widgets/utils/quick/qml/BecUi/Card.qml | 56 ++ .../utils/quick/qml/BecUi/FieldFrame.qml | 12 + bec_widgets/utils/quick/qml/BecUi/Icon.qml | 25 + .../utils/quick/qml/BecUi/IconButton.qml | 36 ++ .../utils/quick/qml/BecUi/InputField.qml | 38 ++ .../utils/quick/qml/BecUi/SelectField.qml | 88 +++ .../utils/quick/qml/BecUi/StatusPill.qml | 40 ++ .../utils/quick/qml/BecUi/SwitchField.qml | 34 ++ .../utils/quick/qml/BecUi/TextButton.qml | 65 +++ bec_widgets/utils/quick/qml/BecUi/qmldir | 10 + .../widgets/containers/dock_area/dock_area.py | 22 +- .../utility/command_palette/__init__.py | 1 + .../command_palette/command_palette.py | 200 +++++++ .../command_palette/command_palette_qml.py | 59 ++ .../command_palette_qwidget.py | 462 ++++++++++++++++ .../utility/command_palette/palette_core.py | 512 ++++++++++++++++++ .../command_palette/palette_sources.py | 264 +++++++++ .../qml/CommandPaletteView.qml | 381 +++++++++++++ tests/unit_tests/test_command_palette.py | 351 ++++++++++++ 24 files changed, 3051 insertions(+), 2 deletions(-) create mode 100644 bec_widgets/utils/quick/__init__.py create mode 100644 bec_widgets/utils/quick/host.py create mode 100644 bec_widgets/utils/quick/list_model.py create mode 100644 bec_widgets/utils/quick/qml/BecUi/Card.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/Icon.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/IconButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/InputField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SelectField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/StatusPill.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/SwitchField.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/TextButton.qml create mode 100644 bec_widgets/utils/quick/qml/BecUi/qmldir create mode 100644 bec_widgets/widgets/utility/command_palette/__init__.py create mode 100644 bec_widgets/widgets/utility/command_palette/command_palette.py create mode 100644 bec_widgets/widgets/utility/command_palette/command_palette_qml.py create mode 100644 bec_widgets/widgets/utility/command_palette/command_palette_qwidget.py create mode 100644 bec_widgets/widgets/utility/command_palette/palette_core.py create mode 100644 bec_widgets/widgets/utility/command_palette/palette_sources.py create mode 100644 bec_widgets/widgets/utility/command_palette/qml/CommandPaletteView.qml create mode 100644 tests/unit_tests/test_command_palette.py diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py index e238599d..7156a6ac 100644 --- a/bec_widgets/applications/main_app.py +++ b/bec_widgets/applications/main_app.py @@ -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() diff --git a/bec_widgets/applications/views/dock_area_view/dock_area_view.py b/bec_widgets/applications/views/dock_area_view/dock_area_view.py index cb143d6a..3430b384 100644 --- a/bec_widgets/applications/views/dock_area_view/dock_area_view.py +++ b/bec_widgets/applications/views/dock_area_view/dock_area_view.py @@ -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), + ] diff --git a/bec_widgets/utils/quick/__init__.py b/bec_widgets/utils/quick/__init__.py new file mode 100644 index 00000000..dcc683f1 --- /dev/null +++ b/bec_widgets/utils/quick/__init__.py @@ -0,0 +1,26 @@ +"""Shared plumbing for widgets that render their content with Qt Quick (QML). + +The QML ports keep the usual ``BECWidget`` shell and public API and only swap the rendering +layer: a :class:`~qtpy.QtQuickWidgets.QQuickWidget` is embedded into the existing widget and +driven by a small Python backend object. +""" + +from bec_widgets.utils.quick.host import ( + QML_IMPORT_PATH, + MaterialIconProvider, + QmlTheme, + create_quick_widget, + quick_engine, + release_quick_widget, +) +from bec_widgets.utils.quick.list_model import DictListModel + +__all__ = [ + "QML_IMPORT_PATH", + "DictListModel", + "MaterialIconProvider", + "QmlTheme", + "create_quick_widget", + "quick_engine", + "release_quick_widget", +] diff --git a/bec_widgets/utils/quick/host.py b/bec_widgets/utils/quick/host.py new file mode 100644 index 00000000..0df3b172 --- /dev/null +++ b/bec_widgets/utils/quick/host.py @@ -0,0 +1,233 @@ +"""Hosting helpers for QML views embedded into BEC widgets. + +All QML views share one :class:`QQmlEngine` per application. The engine carries the +application-wide ``theme`` context property (a :class:`QmlTheme` bridging ``app.theme`` from +``bec_qthemes``), an ``image://material/`` provider for Material icons and the import path +of the shared ``BecUi`` control module. Per-widget state is handed to the view through initial +properties of the root item, typically a single ``backend`` QObject. +""" + +from __future__ import annotations + +from pathlib import Path +from urllib.parse import parse_qs + +from bec_lib.logger import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Property, QObject, QSize, Qt, QUrl, Signal +from qtpy.QtGui import QColor, QPixmap +from qtpy.QtQml import QQmlEngine +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +QML_IMPORT_PATH = Path(__file__).parent / "qml" + +_DARK_FALLBACK = { + "PRIMARY": "#3b82f6", + "ON_PRIMARY": "#ffffff", + "BG": "#0f1115", + "CARD_BG": "#171a21", + "FIELD_BG": "#10131a", + "BORDER": "#2a2f3a", + "FG": "#e8ebf1", + "ACCENT_DEFAULT": "#8ab4f7", + "ACCENT_HIGHLIGHT": "#B53565", + "ACCENT_WARNING": "#EAC435", + "ACCENT_EMERGENCY": "#CC181E", + "ACCENT_SUCCESS": "#2CA58D", +} + + +def _blend(base: QColor, top: QColor, amount: float) -> QColor: + """Return ``top`` mixed into ``base`` by ``amount`` (0 = base, 1 = top).""" + return QColor.fromRgbF( + base.redF() + (top.redF() - base.redF()) * amount, + base.greenF() + (top.greenF() - base.greenF()) * amount, + base.blueF() + (top.blueF() - base.blueF()) * amount, + 1.0, + ) + + +class ThemeTokens: + """Resolved colour tokens of the active BEC theme, shared by the QML and QWidget ports.""" + + def __init__(self): + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + name = getattr(theme, "theme", "dark") if theme is not None else "dark" + + def color(key: str) -> QColor: + fallback = _DARK_FALLBACK.get(key, "#808080") + if theme is None: + return QColor(fallback) + return QColor(theme.color(key, fallback)) + + self.name: str = str(name) + self.bg = color("BG") + self.card = color("CARD_BG") + self.field = color("FIELD_BG") + self.border = color("BORDER") + self.fg = color("FG") + self.primary = color("PRIMARY") + self.on_primary = color("ON_PRIMARY") + self.accent = color("ACCENT_DEFAULT") + self.highlight = color("ACCENT_HIGHLIGHT") + self.warning = color("ACCENT_WARNING") + self.danger = color("ACCENT_EMERGENCY") + self.success = color("ACCENT_SUCCESS") + self.dark = self.bg.lightnessF() < 0.5 + self.fg_muted = _blend(self.card, self.fg, 0.62) + self.fg_subtle = _blend(self.card, self.fg, 0.4) + self.hover = _blend(self.card, self.fg, 0.06) + self.pressed = _blend(self.card, self.fg, 0.12) + self.track = _blend(self.card, self.fg, 0.1) + + def soft(self, color: QColor, amount: float = 0.16) -> QColor: + """Tint ``color`` onto the card background, e.g. for chips and banners.""" + return _blend(self.card, color, amount) + + +class QmlTheme(QObject): + """Expose the BEC theme colours to QML as ``theme.`` and follow theme changes.""" + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._tokens = ThemeTokens() + self._connected_theme = None + self._connect_theme() + + def _connect_theme(self) -> None: + app = QApplication.instance() + theme = getattr(app, "theme", None) if app is not None else None + if theme is None or theme is self._connected_theme: + return + theme.theme_changed.connect(self.refresh) + self._connected_theme = theme + + def refresh(self, *_args) -> None: + """Re-read the tokens from the application theme.""" + self._connect_theme() + self._tokens = ThemeTokens() + self.changed.emit() + + # pylint: disable=missing-function-docstring + def _get(self, name: str) -> QColor: + return getattr(self._tokens, name) + + name = Property(str, lambda self: self._tokens.name, notify=changed) + dark = Property(bool, lambda self: self._tokens.dark, notify=changed) + bg = Property(QColor, lambda self: self._get("bg"), notify=changed) + card = Property(QColor, lambda self: self._get("card"), notify=changed) + field = Property(QColor, lambda self: self._get("field"), notify=changed) + border = Property(QColor, lambda self: self._get("border"), notify=changed) + fg = Property(QColor, lambda self: self._get("fg"), notify=changed) + fgMuted = Property(QColor, lambda self: self._get("fg_muted"), notify=changed) + fgSubtle = Property(QColor, lambda self: self._get("fg_subtle"), notify=changed) + hover = Property(QColor, lambda self: self._get("hover"), notify=changed) + pressed = Property(QColor, lambda self: self._get("pressed"), notify=changed) + track = Property(QColor, lambda self: self._get("track"), notify=changed) + primary = Property(QColor, lambda self: self._get("primary"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("on_primary"), notify=changed) + accent = Property(QColor, lambda self: self._get("accent"), notify=changed) + highlight = Property(QColor, lambda self: self._get("highlight"), notify=changed) + warning = Property(QColor, lambda self: self._get("warning"), notify=changed) + danger = Property(QColor, lambda self: self._get("danger"), notify=changed) + success = Property(QColor, lambda self: self._get("success"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """Serve Material icons to QML as ``image://material/?color=%23rrggbb&filled=1``.""" + + def __init__(self): + super().__init__(QQuickImageProvider.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + options = parse_qs(query) + color = options.get("color", [None])[0] + filled = options.get("filled", ["0"])[0] in ("1", "true") + target = requested_size if requested_size.isValid() else QSize(24, 24) + if target.width() <= 0 or target.height() <= 0: + target = QSize(24, 24) + pixmap = material_icon(name, size=target, color=color, filled=filled) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +_ENGINE: QQmlEngine | None = None +_THEME: QmlTheme | None = None + + +def quick_engine() -> QQmlEngine: + """Return the application-wide QML engine used by all BEC QML views.""" + global _ENGINE, _THEME # pylint: disable=global-statement + if _ENGINE is not None: + if _THEME._connected_theme is None: # pylint: disable=protected-access + # the theme was applied after the engine was created + _THEME.refresh() + return _ENGINE + app = QApplication.instance() + _ENGINE = QQmlEngine(app) + _ENGINE.addImportPath(str(QML_IMPORT_PATH)) + _ENGINE.addImageProvider("material", MaterialIconProvider()) + _THEME = QmlTheme(_ENGINE) + _ENGINE.rootContext().setContextProperty("theme", _THEME) + _ENGINE.warnings.connect( + lambda warnings: [logger.warning(f"QML: {w.toString()}") for w in warnings] + ) + if app is not None: + app.aboutToQuit.connect(_drop_engine) + return _ENGINE + + +def _drop_engine() -> None: + global _ENGINE, _THEME # pylint: disable=global-statement + _ENGINE = None + _THEME = None + + +def create_quick_widget( + parent: QWidget, qml_file: str | Path, properties: dict | None = None +) -> QQuickWidget: + """Create a QQuickWidget showing ``qml_file`` on the shared engine. + + Args: + parent(QWidget): Parent widget, usually the BEC widget shell. + qml_file(str | Path): Path of the root QML file. + properties(dict | None): Initial properties of the root item, e.g. ``{"backend": obj}``. + + Returns: + QQuickWidget: The view, already loaded. Load errors are logged. + """ + view = QQuickWidget(quick_engine(), parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setAttribute(Qt.WidgetAttribute.WA_AlwaysStackOnTop, False) + view.setClearColor(parent.palette().window().color()) + if properties: + view.setInitialProperties(properties) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + for error in view.errors(): + logger.error(f"QML error in {qml_file}: {error.toString()}") + return view + + +def release_quick_widget(view: QQuickWidget | None) -> None: + """Unload the QML scene of ``view`` before its backend objects are destroyed. + + Without this, bindings of a still-loaded scene are re-evaluated against already deleted + backends during teardown and log ``TypeError`` warnings. + """ + if view is None: + return + try: + view.setSource(QUrl()) + except RuntimeError: + # the C++ object is already gone + return diff --git a/bec_widgets/utils/quick/list_model.py b/bec_widgets/utils/quick/list_model.py new file mode 100644 index 00000000..6351b93d --- /dev/null +++ b/bec_widgets/utils/quick/list_model.py @@ -0,0 +1,83 @@ +"""A small list model that feeds lists of dictionaries to QML views.""" + +from __future__ import annotations + +from typing import Any, Sequence + +from qtpy.QtCore import Property, QAbstractListModel, QByteArray, QModelIndex, Qt, Signal + + +class DictListModel(QAbstractListModel): + """List model whose rows are plain dictionaries with a fixed set of keys. + + Each key becomes a QML role. :meth:`set_items` updates rows in place when the row count is + unchanged, so QML delegates are kept alive and ``Behavior`` animations run on value changes + instead of the delegates being rebuilt. + + Args: + roles(Sequence[str]): Keys of the row dictionaries exposed as roles. + parent: Parent QObject. + """ + + count_changed = Signal() + + def __init__(self, roles: Sequence[str], parent=None): + super().__init__(parent) + self._keys = list(roles) + self._roles = {Qt.ItemDataRole.UserRole + 1 + i: key for i, key in enumerate(self._keys)} + self._items: list[dict[str, Any]] = [] + + # pylint: disable=invalid-name + def roleNames(self) -> dict[int, QByteArray]: + return {role: QByteArray(key.encode()) for role, key in self._roles.items()} + + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: + if parent.isValid(): + return 0 + return len(self._items) + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + if not index.isValid() or not 0 <= index.row() < len(self._items): + return None + key = self._roles.get(role) + if key is None: + return None + return self._items[index.row()].get(key) + + @property + def items(self) -> list[dict[str, Any]]: + """Copy of the current rows.""" + return [dict(item) for item in self._items] + + def set_items(self, items: Sequence[dict[str, Any]]) -> None: + """Replace the rows, reusing existing rows where possible. + + Args: + items(Sequence[dict]): New rows. + """ + items = [dict(item) for item in items] + old_count = len(self._items) + new_count = len(items) + common = min(old_count, new_count) + for row in range(common): + changed = [ + role + for role, key in self._roles.items() + if self._items[row].get(key) != items[row].get(key) + ] + self._items[row] = items[row] + if changed: + index = self.index(row, 0) + self.dataChanged.emit(index, index, changed) + if new_count > old_count: + self.beginInsertRows(QModelIndex(), old_count, new_count - 1) + self._items.extend(items[old_count:]) + self.endInsertRows() + elif new_count < old_count: + self.beginRemoveRows(QModelIndex(), new_count, old_count - 1) + del self._items[new_count:] + self.endRemoveRows() + if new_count != old_count: + self.count_changed.emit() + + count = Property(int, lambda self: len(self._items), notify=count_changed) diff --git a/bec_widgets/utils/quick/qml/BecUi/Card.qml b/bec_widgets/utils/quick/qml/BecUi/Card.qml new file mode 100644 index 00000000..2451958f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Card.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml new file mode 100644 index 00000000..aad88d6a --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/FieldFrame.qml @@ -0,0 +1,12 @@ +import QtQuick + +// Background used by all input controls: field colour, border, focus and error states. +Rectangle { + property bool focused: false + property bool invalid: false + property bool hovered: false + radius: 6 + color: theme.field + border.width: focused || invalid ? 2 : 1 + border.color: invalid ? theme.danger : focused ? theme.primary : hovered ? theme.fgSubtle : theme.border +} diff --git a/bec_widgets/utils/quick/qml/BecUi/Icon.qml b/bec_widgets/utils/quick/qml/BecUi/Icon.qml new file mode 100644 index 00000000..d9fbedc4 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/Icon.qml @@ -0,0 +1,25 @@ +import QtQuick + +// Material icon served by the "material" image provider, tinted with the given colour. +Item { + id: root + property string name: "" + property color color: theme.fg + property bool filled: false + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.centerIn: parent + width: root.size + height: root.size + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + smooth: true + fillMode: Image.PreserveAspectFit + source: root.name === "" ? "" : "image://material/" + root.name + "?color=" + + encodeURIComponent(root.color.toString()) + (root.filled ? "&filled=1" : "") + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/IconButton.qml b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml new file mode 100644 index 00000000..9603aa81 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/IconButton.qml @@ -0,0 +1,36 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Flat, square icon button with hover and pressed feedback and a tooltip. +AbstractButton { + id: root + property string iconName: "" + property color iconColor: theme.fgMuted + property string tip: "" + property int iconSize: 18 + + implicitWidth: 30 + implicitHeight: 30 + hoverEnabled: true + focusPolicy: Qt.TabFocus + opacity: enabled ? 1.0 : 0.4 + Accessible.name: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : (root.hovered || root.checked ? theme.hover : "transparent") + border.width: root.visualFocus ? 1 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: root.checked ? theme.primary : root.iconColor + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/InputField.qml b/bec_widgets/utils/quick/qml/BecUi/InputField.qml new file mode 100644 index 00000000..0ed06d5f --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/InputField.qml @@ -0,0 +1,38 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Single-line text input with optional unit suffix and error state. +TextField { + id: root + property bool invalid: false + property string suffix: "" + + implicitHeight: 32 + implicitWidth: 120 + color: theme.fg + placeholderTextColor: theme.fgSubtle + selectionColor: theme.primary + selectedTextColor: theme.onPrimary + font.pixelSize: 13 + leftPadding: 9 + rightPadding: suffixLabel.visible ? suffixLabel.implicitWidth + 14 : 9 + selectByMouse: true + hoverEnabled: true + opacity: enabled ? 1.0 : 0.5 + + background: FieldFrame { + focused: root.activeFocus + invalid: root.invalid + hovered: root.hovered + } + Text { + id: suffixLabel + visible: root.suffix !== "" + text: root.suffix + color: theme.fgSubtle + font.pixelSize: 12 + anchors.right: parent.right + anchors.rightMargin: 9 + anchors.verticalCenter: parent.verticalCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SelectField.qml b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml new file mode 100644 index 00000000..2d4f814b --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SelectField.qml @@ -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 + } + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml new file mode 100644 index 00000000..7901c832 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/StatusPill.qml @@ -0,0 +1,40 @@ +import QtQuick + +// Small rounded status chip with a coloured dot. Set pulse for live states such as "Moving". +Rectangle { + id: root + property string text: "" + property color tone: theme.fgMuted + property bool pulse: false + + implicitHeight: 22 + implicitWidth: label.implicitWidth + 26 + radius: height / 2 + color: Qt.rgba(tone.r, tone.g, tone.b, 0.16) + + Rectangle { + id: dot + width: 7; height: 7; radius: 3.5 + color: root.tone + anchors.left: parent.left + anchors.leftMargin: 9 + anchors.verticalCenter: parent.verticalCenter + SequentialAnimation on opacity { + running: root.pulse + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { to: 0.25; duration: 550; easing.type: Easing.InOutQuad } + NumberAnimation { to: 1.0; duration: 550; easing.type: Easing.InOutQuad } + } + } + Text { + id: label + anchors.left: dot.right + anchors.leftMargin: 6 + anchors.verticalCenter: parent.verticalCenter + text: root.text + color: root.tone + font.pixelSize: 12 + font.weight: Font.DemiBold + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml new file mode 100644 index 00000000..a21f3221 --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/SwitchField.qml @@ -0,0 +1,34 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Toggle switch for boolean parameters. +Switch { + id: root + implicitHeight: 32 + hoverEnabled: true + padding: 0 + indicator: Rectangle { + implicitWidth: 38 + implicitHeight: 22 + x: root.leftPadding + y: (root.height - height) / 2 + radius: 11 + color: root.checked ? theme.primary : theme.track + border.color: root.visualFocus ? theme.primary : (root.checked ? theme.primary : theme.border) + Behavior on color { ColorAnimation { duration: 120 } } + Rectangle { + x: root.checked ? parent.width - width - 3 : 3 + y: 3 + width: 16; height: 16; radius: 8 + color: root.checked ? theme.onPrimary : theme.fgMuted + Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } + } + } + contentItem: Text { + leftPadding: root.indicator.width + 8 + text: root.text + color: theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } +} diff --git a/bec_widgets/utils/quick/qml/BecUi/TextButton.qml b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml new file mode 100644 index 00000000..45b069dc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/TextButton.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. variant: "primary", "danger", "success", "neutral" or "ghost". +AbstractButton { + id: root + property string variant: "neutral" + property string iconName: "" + property string tip: "" + property bool busy: false + + readonly property color baseColor: variant === "primary" ? theme.primary + : variant === "danger" ? theme.danger + : variant === "success" ? theme.success + : "transparent" + readonly property bool solid: variant === "primary" || variant === "danger" || variant === "success" + readonly property color labelColor: solid ? "white" : theme.fg + + implicitHeight: 32 + implicitWidth: Math.max(64, row.implicitWidth + 24) + hoverEnabled: true + opacity: enabled ? 1.0 : 0.45 + Accessible.name: text + + background: Rectangle { + radius: 6 + color: root.solid ? (root.down ? Qt.darker(root.baseColor, 1.25) : root.hovered ? Qt.lighter(root.baseColor, 1.1) : root.baseColor) + : (root.down ? theme.pressed : root.hovered ? theme.hover : "transparent") + border.width: root.variant === "neutral" || root.visualFocus ? 1 : 0 + border.color: root.visualFocus ? theme.primary : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: Item { + implicitWidth: row.implicitWidth + implicitHeight: row.implicitHeight + RowLayout { + id: row + anchors.centerIn: parent + spacing: 6 + BusyIndicator { + visible: root.busy + running: root.busy + Layout.preferredWidth: 16 + Layout.preferredHeight: 16 + } + Icon { + visible: root.iconName !== "" && !root.busy + name: root.iconName + color: root.labelColor + size: 16 + } + Text { + text: root.text + color: root.labelColor + font.pixelSize: 13 + font.weight: root.solid ? Font.DemiBold : Font.Medium + elide: Text.ElideRight + } + } + } + ToolTip.visible: tip !== "" && hovered + ToolTip.delay: 500 + ToolTip.text: tip +} diff --git a/bec_widgets/utils/quick/qml/BecUi/qmldir b/bec_widgets/utils/quick/qml/BecUi/qmldir new file mode 100644 index 00000000..160907bc --- /dev/null +++ b/bec_widgets/utils/quick/qml/BecUi/qmldir @@ -0,0 +1,10 @@ +module BecUi +Icon 1.0 Icon.qml +IconButton 1.0 IconButton.qml +TextButton 1.0 TextButton.qml +StatusPill 1.0 StatusPill.qml +Card 1.0 Card.qml +FieldFrame 1.0 FieldFrame.qml +InputField 1.0 InputField.qml +SelectField 1.0 SelectField.qml +SwitchField 1.0 SwitchField.qml diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 8d7e5f54..c33f0ad0 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -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( diff --git a/bec_widgets/widgets/utility/command_palette/__init__.py b/bec_widgets/widgets/utility/command_palette/__init__.py new file mode 100644 index 00000000..531b9dfb --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/__init__.py @@ -0,0 +1 @@ +"""Ctrl+K command palette with a QWidget and a QML rendering of the same search card.""" diff --git a/bec_widgets/widgets/utility/command_palette/command_palette.py b/bec_widgets/widgets/utility/command_palette/command_palette.py new file mode 100644 index 00000000..de532487 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/command_palette.py @@ -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() diff --git a/bec_widgets/widgets/utility/command_palette/command_palette_qml.py b/bec_widgets/widgets/utility/command_palette/command_palette_qml.py new file mode 100644 index 00000000..191171b9 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/command_palette_qml.py @@ -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) diff --git a/bec_widgets/widgets/utility/command_palette/command_palette_qwidget.py b/bec_widgets/widgets/utility/command_palette/command_palette_qwidget.py new file mode 100644 index 00000000..faafe025 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/command_palette_qwidget.py @@ -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"{{}}" + self.footer_hints.setText( + "   ".join( + f"{key.format(k)} {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 diff --git a/bec_widgets/widgets/utility/command_palette/palette_core.py b/bec_widgets/widgets/utility/command_palette/palette_core.py new file mode 100644 index 00000000..56dc3ef0 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/palette_core.py @@ -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'{html.escape(text)}' 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() diff --git a/bec_widgets/widgets/utility/command_palette/palette_sources.py b/bec_widgets/widgets/utility/command_palette/palette_sources.py new file mode 100644 index 00000000..55883265 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/palette_sources.py @@ -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 ``" › "``. + """ + 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 diff --git a/bec_widgets/widgets/utility/command_palette/qml/CommandPaletteView.qml b/bec_widgets/widgets/utility/command_palette/qml/CommandPaletteView.qml new file mode 100644 index 00000000..16f2d649 --- /dev/null +++ b/bec_widgets/widgets/utility/command_palette/qml/CommandPaletteView.qml @@ -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: "" + modelData[0] + " " + 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 + } + } + } +} diff --git a/tests/unit_tests/test_command_palette.py b/tests/unit_tests/test_command_palette.py new file mode 100644 index 00000000..d522b2f8 --- /dev/null +++ b/tests/unit_tests/test_command_palette.py @@ -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("aa<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)