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