From 840773e4baffaa802899e9c09d216c174fe6accf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:05:22 +0000 Subject: [PATCH 1/2] feat(bec_queue): port queue widget to QML with state, progress, history and safer actions Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F2iSCsSSznEdY4HxoVaqoi --- bec_widgets/utils/qml_host.py | 205 +++++++ .../widgets/services/bec_queue/bec_queue.py | 343 +++++------- .../services/bec_queue/qml/BECQueue.qml | 333 ++++++++++++ .../widgets/services/bec_queue/qml/Icon.qml | 24 + .../services/bec_queue/qml/IconButton.qml | 37 ++ .../services/bec_queue/qml/QueueRow.qml | 235 ++++++++ .../services/bec_queue/qml/StatusPill.qml | 45 ++ .../services/bec_queue/qml/TextButton.qml | 55 ++ .../widgets/services/bec_queue/queue_model.py | 514 ++++++++++++++++++ tests/unit_tests/test_bec_queue.py | 347 ++++++++---- .../test_bec_widget_cell_widgets.py | 12 +- 11 files changed, 1810 insertions(+), 340 deletions(-) create mode 100644 bec_widgets/utils/qml_host.py create mode 100644 bec_widgets/widgets/services/bec_queue/qml/BECQueue.qml create mode 100644 bec_widgets/widgets/services/bec_queue/qml/Icon.qml create mode 100644 bec_widgets/widgets/services/bec_queue/qml/IconButton.qml create mode 100644 bec_widgets/widgets/services/bec_queue/qml/QueueRow.qml create mode 100644 bec_widgets/widgets/services/bec_queue/qml/StatusPill.qml create mode 100644 bec_widgets/widgets/services/bec_queue/qml/TextButton.qml create mode 100644 bec_widgets/widgets/services/bec_queue/queue_model.py diff --git a/bec_widgets/utils/qml_host.py b/bec_widgets/utils/qml_host.py new file mode 100644 index 00000000..84c02040 --- /dev/null +++ b/bec_widgets/utils/qml_host.py @@ -0,0 +1,205 @@ +""" +Helpers for hosting a QML view inside a BEC widget. + +A QML-based widget keeps its QWidget shell (``BECWidget`` + a Qt widget class) so RPC, the +Designer plugin and docking work unchanged. The shell embeds a ``QQuickWidget`` created with +:func:`create_quick_widget`, which wires up two things every BEC QML view needs: + +* ``theme`` — a :class:`QmlTheme` context property mirroring the active ``bec_qthemes`` palette, + updated live when the application theme changes; +* ``image://material/`` — Material icons rendered by ``bec_qthemes.material_icon``, + e.g. ``image://material/pause?color=40B6E0&filled=1``. +""" + +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 QQmlImageProviderBase +from qtpy.QtQuick import QQuickImageProvider +from qtpy.QtQuickWidgets import QQuickWidget +from qtpy.QtWidgets import QApplication, QWidget + +logger = bec_logger.logger + +# Fallback palette (bec_qthemes "dark") used when no theme has been applied yet. +_FALLBACK = { + "BG": "#1e1f22", + "CARD_BG": "#2b2c2f", + "FIELD_BG": "#26272a", + "FG": "#dddddd", + "BORDER": "#464749", + "PRIMARY": "#6089ef", + "ON_PRIMARY": "#ffffff", + "ACCENT_DEFAULT": "#40B6E0", + "ACCENT_WARNING": "#EDA200", + "ACCENT_EMERGENCY": "#E0534B", + "ACCENT_SUCCESS": "#59A869", +} + + +def _mix(a: QColor, b: QColor, t: float) -> QColor: + """Blend colour ``a`` towards ``b`` by ``t`` (0 = a, 1 = b).""" + return QColor.fromRgbF( + a.redF() + (b.redF() - a.redF()) * t, + a.greenF() + (b.greenF() - a.greenF()) * t, + a.blueF() + (b.blueF() - a.blueF()) * t, + 1.0, + ) + + +def _readable_on(color: QColor, background: QColor, is_dark: bool) -> QColor: + """Return ``color`` adjusted so it stays legible as text on ``background``.""" + if is_dark: + return _mix(color, QColor("#ffffff"), 0.15) + # Accent colours such as the light theme's yellow are too pale for text on white. + return _mix(color, QColor("#000000"), 0.35 if color.lightnessF() > 0.5 else 0.1) + + +class QmlTheme(QObject): + """ + The active ``bec_qthemes`` palette as QML-readable properties. + + Base colours come straight from the theme; derived colours (muted text, hover fills, state + text and tints) are computed here so every QML view uses the same rules. + """ + + changed = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._c: dict[str, QColor] = {} + self._dark = True + self._reload() + app = QApplication.instance() + theme = getattr(app, "theme", None) + if theme is not None and hasattr(theme, "theme_changed"): + theme.theme_changed.connect(self._reload) + + def _reload(self, *_args): + app = QApplication.instance() + theme = getattr(app, "theme", None) + colors = {k: QColor(v) for k, v in _FALLBACK.items()} + if theme is not None: + try: + colors.update({k: QColor(v) for k, v in theme.colors.items()}) + except Exception: # pragma: no cover - defensive, theme API drift + logger.warning("Could not read the bec_qthemes palette; using defaults.") + bg, fg = colors["BG"], colors["FG"] + self._dark = bg.lightnessF() < 0.5 + card = colors["CARD_BG"] + c = dict(colors) + c["MUTED"] = _mix(fg, card, 0.38) + c["FAINT"] = _mix(fg, card, 0.6) + c["HOVER"] = _mix(card, fg, 0.07) + c["PRESSED"] = _mix(card, fg, 0.13) + c["SEPARATOR"] = _mix(card, fg, 0.1) + for key, name in ( + ("ACCENT_DEFAULT", "BUSY"), + ("ACCENT_SUCCESS", "OK"), + ("ACCENT_WARNING", "WARN"), + ("ACCENT_EMERGENCY", "ERR"), + ): + c[f"{name}_TEXT"] = _readable_on(colors[key], card, self._dark) + c[f"{name}_TINT"] = _mix(card, colors[key], 0.18 if self._dark else 0.12) + self._c = c + self.changed.emit() + + def _get(self, key: str) -> QColor: + return self._c.get(key, QColor("#ff00ff")) + + # pylint: disable=missing-function-docstring + isDark = Property(bool, lambda self: self._dark, notify=changed) + bg = Property(QColor, lambda self: self._get("BG"), notify=changed) + card = Property(QColor, lambda self: self._get("CARD_BG"), notify=changed) + field = Property(QColor, lambda self: self._get("FIELD_BG"), notify=changed) + fg = Property(QColor, lambda self: self._get("FG"), notify=changed) + muted = Property(QColor, lambda self: self._get("MUTED"), notify=changed) + faint = Property(QColor, lambda self: self._get("FAINT"), notify=changed) + border = Property(QColor, lambda self: self._get("BORDER"), notify=changed) + separator = Property(QColor, lambda self: self._get("SEPARATOR"), notify=changed) + hover = Property(QColor, lambda self: self._get("HOVER"), notify=changed) + pressed = Property(QColor, lambda self: self._get("PRESSED"), notify=changed) + primary = Property(QColor, lambda self: self._get("PRIMARY"), notify=changed) + onPrimary = Property(QColor, lambda self: self._get("ON_PRIMARY"), notify=changed) + busy = Property(QColor, lambda self: self._get("ACCENT_DEFAULT"), notify=changed) + ok = Property(QColor, lambda self: self._get("ACCENT_SUCCESS"), notify=changed) + warn = Property(QColor, lambda self: self._get("ACCENT_WARNING"), notify=changed) + err = Property(QColor, lambda self: self._get("ACCENT_EMERGENCY"), notify=changed) + busyText = Property(QColor, lambda self: self._get("BUSY_TEXT"), notify=changed) + okText = Property(QColor, lambda self: self._get("OK_TEXT"), notify=changed) + warnText = Property(QColor, lambda self: self._get("WARN_TEXT"), notify=changed) + errText = Property(QColor, lambda self: self._get("ERR_TEXT"), notify=changed) + busyTint = Property(QColor, lambda self: self._get("BUSY_TINT"), notify=changed) + okTint = Property(QColor, lambda self: self._get("OK_TINT"), notify=changed) + warnTint = Property(QColor, lambda self: self._get("WARN_TINT"), notify=changed) + errTint = Property(QColor, lambda self: self._get("ERR_TINT"), notify=changed) + + +class MaterialIconProvider(QQuickImageProvider): + """ + Serves ``bec_qthemes`` Material icons to QML. + + URL form: ``image://material/?color=&filled=1``. + """ + + def __init__(self): + super().__init__(QQmlImageProviderBase.ImageType.Pixmap) + + def requestPixmap(self, icon_id: str, size: QSize, requested_size: QSize) -> QPixmap: + name, _, query = icon_id.partition("?") + params = parse_qs(query) + color = params.get("color", [None])[0] + filled = params.get("filled", ["0"])[0] == "1" + if requested_size.isValid() and requested_size.width() > 0: + target = requested_size + else: + target = QSize(24, 24) + pixmap = material_icon( + name, + size=(target.width(), target.height()), + color=f"#{color}" if color else None, + filled=filled, + convert_to_pixmap=True, + ) + if size is not None: + size.setWidth(pixmap.width()) + size.setHeight(pixmap.height()) + return pixmap + + +def create_quick_widget( + parent: QWidget, qml_file: Path, context: dict[str, QObject] +) -> QQuickWidget: + """ + Create a ``QQuickWidget`` that loads ``qml_file`` with the BEC theme and icon provider. + + Args: + parent(QWidget): Parent widget (the BEC widget shell). + qml_file(Path): The root QML file. + context(dict[str, QObject]): Extra context properties, e.g. the widget's controller. + + Returns: + QQuickWidget: The view, already loaded. QML errors are logged. + """ + view = QQuickWidget(parent) + view.setResizeMode(QQuickWidget.ResizeMode.SizeRootObjectToView) + view.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + engine = view.engine() + engine.addImageProvider("material", MaterialIconProvider()) + theme = QmlTheme(view) + view.rootContext().setContextProperty("theme", theme) + for name, obj in context.items(): + view.rootContext().setContextProperty(name, obj) + view.setClearColor(theme.card) + theme.changed.connect(lambda: view.setClearColor(theme.card)) + view.setSource(QUrl.fromLocalFile(str(qml_file))) + if view.status() == QQuickWidget.Status.Error: + for error in view.errors(): + logger.error(f"QML error in {qml_file.name}: {error.toString()}") + return view diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 8936d23a..28ff1155 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -1,47 +1,41 @@ from __future__ import annotations -import json +import threading +from pathlib import Path from bec_lib import messages from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger -from bec_qthemes import material_icon -from qtpy.QtCore import Property, Qt, Signal, Slot -from qtpy.QtGui import QColor -from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget +from qtpy.QtCore import Property, QUrl, Signal +from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.compact_popup import CompactPopupWidget -from bec_widgets.utils.toolbars.actions import WidgetAction -from bec_widgets.utils.toolbars.bundles import ToolbarBundle -from bec_widgets.utils.toolbars.toolbar import ModularToolBar -from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton -from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton -from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton -from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.qml_host import create_quick_widget +from bec_widgets.widgets.services.bec_queue.queue_model import IDLE_STATUSES, QueueController logger = bec_logger.logger +QML_FILE = Path(__file__).parent / "qml" / "BECQueue.qml" + class BECQueue(BECWidget, CompactPopupWidget): """ Widget to display the BEC queue. + + Shows the running scan with its progress, the waiting scans (reorder, remove) and the most + recent finished scans, together with the queue state and Pause / Resume / Abort controls. + The view is written in QML and hosted in a ``QQuickWidget``; this class is the QWidget shell + that keeps the RPC, Designer and docking interface. """ PLUGIN = True ICON_NAME = "edit_note" - status_colors = { - "STOPPED": "red", - "PENDING": "orange", - "IDLE": "gray", - "PAUSED": "yellow", - "DEFERRED_PAUSE": "lightyellow", - "RUNNING": "green", - "COMPLETED": "blue", - } queue_busy = Signal(bool) + _history_ready = Signal(list) def __init__( self, @@ -57,59 +51,32 @@ class BECQueue(BECWidget, CompactPopupWidget): ) self.layout.setSpacing(0) self.layout.setContentsMargins(0, 0, 0, 0) + self.get_bec_shortcuts() self._toolbar_hidden = False + self._known_queue_ids: set[str] = set() + self._history_thread: threading.Thread | None = None - # Set up the toolbar - self.set_toolbar() - # Set up the table - self.table = QTableWidget(self) - # self.layout.addWidget(self.table) - self.table.setColumnCount(5) - self.table.setHorizontalHeaderLabels( - ["Scan Number", "Scan Name", "Type", "Status", "Cancel"] - ) + self.controller = QueueController(self) + self.controller.pause_requested.connect(self.pause_queue) + self.controller.resume_requested.connect(self.resume_queue) + self.controller.abort_requested.connect(self.abort_request) + self.controller.halt_requested.connect(self.halt_scan) + self.controller.remove_requested.connect(self.abort_request) + self.controller.move_requested.connect(self.move_request) + self.controller.clear_requested.connect(self.clear_queue) + self._history_ready.connect(self.controller.set_history) - header = self.table.horizontalHeader() - header.setSectionResizeMode(QHeaderView.Stretch) - - self.addWidget(self.table) + self.view = create_quick_widget(self, QML_FILE, {"queue": self.controller}) + self.view.setMinimumSize(320, 160) + self.addWidget(self.view) self.label = "BEC Queue" self.tooltip = "BEC Queue status" self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status()) - self.reset_content() + self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress()) if refresh_upon_start: self.refresh_queue() - def set_toolbar(self): - """ - Set the toolbar. - """ - widget_label = QLabel(text="Live Queue", parent=self) - widget_label.setStyleSheet("font-weight: bold;") - self.toolbar = ModularToolBar(parent=self) - self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label)) - bundle = ToolbarBundle("queue_label", self.toolbar.components) - bundle.add_action("widget_label") - self.toolbar.add_bundle(bundle) - - self.toolbar.add_action( - "resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True)) - ) - self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True))) - self.toolbar.add_action( - "reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True)) - ) - - control_bundle = ToolbarBundle("control", self.toolbar.components) - control_bundle.add_action("resume") - control_bundle.add_action("stop") - control_bundle.add_action("reset") - self.toolbar.add_bundle(control_bundle) - self.toolbar.show_bundles(["queue_label", "control"]) - - self.addWidget(self.toolbar) - @Property(bool) def hide_toolbar(self): """Property to hide the BEC Queue toolbar.""" @@ -133,194 +100,134 @@ class BECQueue(BECWidget, CompactPopupWidget): hide(bool): Whether to hide the toolbar. """ self._toolbar_hidden = hide - self.toolbar.setVisible(not hide) + self.controller.set_toolbar_visible(not hide) def refresh_queue(self): """ - Refresh the queue. + Refresh the queue and the recent history. """ msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + self._fetch_history() if msg is None: # msg is None if no scan has been run yet (fresh start) return self.update_queue(msg.content, msg.metadata) - @Slot(dict, dict) + @SafeSlot(dict, dict) def update_queue(self, content, _metadata): """ - Update the queue table with the latest queue information. + Update the view with the latest queue information. Args: content (dict): The queue content. _metadata (dict): The metadata. """ # only show the primary queue for now - queues = content.get("queue", {}) - if not queues: - self.reset_content() - return - primary_queue: messages.ScanQueueStatus | None = queues.get("primary") - if not primary_queue: - self.reset_content() - return - queue_info = primary_queue.info + primary_queue: messages.ScanQueueStatus | None = content.get("queue", {}).get("primary") + self.controller.set_queue(primary_queue) + queue_info = primary_queue.info if primary_queue else [] - self.table.setRowCount(len(queue_info)) - self.table.clearContents() + queue_ids = {item.queue_id for item in queue_info} + if self._known_queue_ids - queue_ids: + # something left the queue: it is now in the history + self._fetch_history() + self._known_queue_ids = queue_ids - if not queue_info: - self.reset_content() - return - - for index, item in enumerate(queue_info): - blocks = item.request_blocks - scan_types = [] - scan_numbers = [] - scan_names = [] - user_metadatas = [] - request_ids = [] - status = item.status - for request_block in blocks: - scan_type = request_block.msg.scan_type - user_metadata = request_block.msg.metadata.get("user_metadata", {}) - scan_name = user_metadata.get("scan_name", scan_type) - if scan_type: - scan_types.append(scan_type) - scan_number = request_block.scan_number - if scan_number: - scan_numbers.append(str(scan_number)) - if scan_name: - scan_names.append(scan_name) - if user_metadata: - user_metadatas.append(user_metadata) - if request_block.RID: - request_ids.append(request_block.RID) - if scan_types: - scan_types = ", ".join(scan_types) - if scan_numbers: - scan_numbers = ", ".join(scan_numbers) - if scan_names: - scan_names = ", ".join(scan_names) - # Pretty print user metadata as tooltip - tooltip = "" - if user_metadatas: - if len(user_metadatas) == 1: - tooltip = json.dumps(user_metadatas[0], indent=2) - else: - tooltip = json.dumps(user_metadatas, indent=2) - self.set_row( - index, - scan_numbers, - scan_names, - scan_types, - status, - tooltip, - abort_request_id=request_ids[0] if request_ids else "", - ) - busy = ( - False - if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info) - else True - ) + busy = not all(item.status in IDLE_STATUSES for item in queue_info) self.set_global_state("warning" if busy else "default") self.queue_busy.emit(busy) - def format_item(self, content: str, status=False, tooltip: str = "") -> QTableWidgetItem: + @SafeSlot(dict, dict) + def on_scan_progress(self, content: dict, metadata: dict): """ - Format the content of the table item. + Show the progress of the running scan. Args: - content (str): The content to be formatted. - tooltip (str): Optional tooltip to display. - Returns: - QTableWidgetItem: The formatted item. + content (dict): ProgressMessage content. + metadata (dict): ProgressMessage metadata. """ - if not content or not isinstance(content, str): - content = "" - item = QTableWidgetItem(content) - item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter) - if tooltip: - item.setToolTip(tooltip) - - if status: - try: - color = self.status_colors.get(content, "black") # Default to black if not found - item.setForeground(QColor(color)) - except Exception as e: - logger.warning(f"Could not apply status color for queue item '{content}': {e}") - return item - - def set_row( - self, - index: int, - scan_number: str, - scan_name: str, - scan_type: str, - status: str, - tooltip: str = "", - abort_request_id: str = "", - ): - """ - Set the row of the table. - - Args: - index (int): The index of the row. - scan_number (str): The scan number. - scan_name (str): The scan name. - scan_type (str): The scan type. - status (str): The status. - tooltip (str): Optional tooltip to display (pretty-printed user metadata). - abort_request_id (str): request id to abort. - """ - abort_button = self._create_abort_button(abort_request_id) - abort_button.button.clicked.connect(self.delete_selected_row) - - self.table.setItem(index, 0, self.format_item(scan_number, tooltip=tooltip)) - self.table.setItem(index, 1, self.format_item(scan_name, tooltip=tooltip)) - self.table.setItem(index, 2, self.format_item(scan_type, tooltip=tooltip)) - self.table.setItem(index, 3, self.format_item(status, status=True, tooltip=tooltip)) - self.table.setCellWidget(index, 4, abort_button) - - def _create_abort_button(self, request_id: str) -> AbortButton: - """ - Create an abort button with styling for BEC Queue widget for certain request_id. - - Args: - request_id(str): The request id to abort. - - Returns: - AbortButton: The abort button. - """ - abort_button = AbortButton(parent=self, request_id=request_id) - - abort_button.button.setText("") - abort_button.button.setIcon( - material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False) + self.controller.set_progress( + metadata.get("scan_id"), + content.get("value", 0), + content.get("max_value", 0), + content.get("done", False), ) - abort_button.setStyleSheet(""" - QPushButton { - background-color: transparent; - border: none; - } - """) - return abort_button + def _fetch_history(self): + """Read the queue history from Redis off the GUI thread.""" + if self._history_thread is not None and self._history_thread.is_alive(): + return - def delete_selected_row(self): + def worker(): + try: + history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, 9) + except Exception as exc: # pylint: disable=broad-except + logger.warning(f"Could not read the queue history: {exc}") + return + if isinstance(history, list): + self._history_ready.emit( + [m for m in history if isinstance(m, messages.ScanQueueHistoryMessage)] + ) - button = self.sender() - row = self.table.indexAt(button.pos()).row() - self.table.removeRow(row) - button.close() - button.deleteLater() + self._history_thread = threading.Thread(target=worker, daemon=True) + self._history_thread.start() - def reset_content(self): - """ - Reset the content of the table. + ################################################################################ + # Requests to BEC + ################################################################################ + + @SafeSlot() + def pause_queue(self): + """Pause the queue: the running scan pauses at its next checkpoint, nothing new starts.""" + self.client.connector.send( + MessageEndpoints.scan_queue_modification_request(), + messages.ScanQueueModificationMessage( + scan_id=None, action="deferred_pause", parameter={} + ), + ) + + @SafeSlot() + def resume_queue(self): + """Resume the queue.""" + self.queue.request_scan_continuation() + + @SafeSlot(str) + def abort_request(self, request_id: str): """ + Abort a request: the running scan is aborted, a waiting one is removed from the queue. - self.table.setRowCount(1) - self.set_row(0, "", "", "", "", "") + Args: + request_id(str): The request to abort; empty aborts the current request. + """ + self.queue.request_scan_abortion(request_id=request_id or None) + + @SafeSlot() + def halt_scan(self): + """Halt the running scan without its cleanup routines.""" + self.queue.request_scan_halt() + + @SafeSlot(str, str) + def move_request(self, scan_id: str, action: str): + """ + Move a waiting scan within the queue. + + Args: + scan_id(str): Scan id of the queue item. + action(str): "move_up", "move_down", "move_top" or "move_bottom". + """ + self.queue.request_queue_order_modification(scan_id=scan_id, action=action) + + @SafeSlot() + def clear_queue(self): + """Stop the running scan and remove every waiting scan.""" + self.queue.request_queue_reset() + + def cleanup(self): + """Release the QML view before the controller it binds to.""" + if self._history_thread is not None: + self._history_thread.join(timeout=1) + self.view.setSource(QUrl()) + super().cleanup() if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/services/bec_queue/qml/BECQueue.qml b/bec_widgets/widgets/services/bec_queue/qml/BECQueue.qml new file mode 100644 index 00000000..d08cc5a6 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/BECQueue.qml @@ -0,0 +1,333 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// BEC scan queue. Context properties: `queue` (QueueController), `theme` (QmlTheme). +Rectangle { + id: root + color: theme.card + + readonly property bool hasCurrent: queue.runningCount > 0 + readonly property bool paused: queue.state === "paused" + readonly property bool locked: queue.state === "locked" + + function stateTone() { + return locked ? "err" : paused ? "warn" : queue.state === "running" ? "busy" : "neutral" + } + function stateLabel() { + return locked ? "Locked" : paused ? "Paused" : queue.state === "running" ? "Running" : "Ready" + } + function stateIcon() { + return locked ? "lock" : paused ? "pause" : queue.state === "running" ? "play_arrow" : "check" + } + function countsText() { + var parts = [] + if (queue.runningCount > 0) parts.push(queue.runningCount + " running") + parts.push(queue.waitingCount + " waiting") + return parts.join(" · ") + } + + // ------------------------------------------------------------------ confirmation popover + Popup { + id: confirm + property string title + property string body + property string confirmText + property var action: null + + property Item anchorItem: null + + function ask(anchor, t, b, c, fn) { + title = t; body = b; confirmText = c; action = fn; anchorItem = anchor + place() + open() + cancelButton.forceActiveFocus() + } + // Below the button that asked, kept inside the view. + function place() { + if (!anchorItem) return + var p = anchorItem.mapToItem(root, 0, anchorItem.height + 6) + x = Math.max(8, Math.min(p.x + anchorItem.width - width, root.width - width - 8)) + y = Math.max(8, Math.min(p.y, root.height - height - 8)) + } + onHeightChanged: place() + + width: 320 + padding: 14 + modal: false + focus: true + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside + background: Rectangle { + color: theme.card + radius: 9 + border.color: theme.border + } + enter: Transition { NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 110 } } + exit: Transition { NumberAnimation { property: "opacity"; from: 1; to: 0; duration: 90 } } + + contentItem: ColumnLayout { + spacing: 8 + Text { + text: confirm.title + color: theme.fg + font.pixelSize: 14 + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + Text { + text: confirm.body + color: theme.muted + font.pixelSize: 12 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + RowLayout { + Layout.topMargin: 4 + Layout.alignment: Qt.AlignRight + spacing: 6 + TextButton { id: cancelButton; text: "Cancel"; onClicked: confirm.close() } + TextButton { + text: confirm.confirmText + kind: "dangerSolid" + onClicked: { confirm.close(); if (confirm.action) confirm.action() } + } + } + } + } + + function askAbort(anchor) { + var scan = queue.currentLabel + var waiting = queue.waitingCount + confirm.ask(anchor, "Abort " + scan + "?", + "Data recorded so far is kept and the cleanup runs. The queue pauses afterwards" + + (waiting > 0 ? "; the " + waiting + " waiting scan" + (waiting > 1 ? "s stay" : " stays") + " queued." : "."), + "Abort scan", function () { queue.abortCurrent() }) + } + + // ------------------------------------------------------------------ layout + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header: state, counts and queue controls + RowLayout { + visible: queue.toolbarVisible + Layout.fillWidth: true + Layout.margins: 10 + Layout.bottomMargin: 8 + spacing: 8 + + StatusPill { + label: root.stateLabel() + iconName: root.stateIcon() + tone: root.stateTone() + Accessible.name: "Queue " + root.stateLabel() + } + Text { + text: root.countsText() + color: theme.muted + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + TextButton { + text: root.paused || root.locked ? "Resume" : "Pause" + iconName: root.paused || root.locked ? "play_arrow" : "pause" + enabled: !root.locked + tip: root.locked ? "The queue is locked: " + queue.lockReason + : root.paused ? "Let the next scan start" + : "Pause the queue: the running scan holds at its next checkpoint, nothing new starts" + onClicked: root.paused ? queue.resume() : queue.pause() + } + TextButton { + id: abortHeader + text: "Abort scan" + iconName: "stop" + kind: "danger" + enabled: root.hasCurrent + tip: root.hasCurrent ? "Abort " + queue.currentLabel + " (asks first)" : "No scan is running" + onClicked: root.askAbort(abortHeader) + } + IconButton { + id: moreButton + iconName: "more_horiz" + tip: "More queue actions" + onClicked: moreMenu.popup(moreButton, 0, moreButton.height + 4) + } + } + + Menu { + id: moreMenu + background: Rectangle { implicitWidth: 260; color: theme.card; radius: 8; border.color: theme.border } + delegate: MenuItem { + id: mi + implicitHeight: 30 + contentItem: Text { + leftPadding: mi.checkable ? 22 : 4 + text: mi.text + color: !mi.enabled ? theme.faint : mi.text.indexOf("Halt") === 0 || mi.text.indexOf("Clear") === 0 ? theme.errText : theme.fg + font.pixelSize: 13 + verticalAlignment: Text.AlignVCenter + } + indicator: Icon { + visible: mi.checkable && mi.checked + x: 8; anchors.verticalCenter: parent.verticalCenter + name: "check"; size: 15; color: theme.fg + } + background: Rectangle { color: mi.highlighted ? theme.hover : "transparent"; radius: 5 } + } + MenuItem { + text: "Show recent scans" + checkable: true + checked: queue.showRecent + onTriggered: queue.showRecent = !queue.showRecent + } + MenuSeparator { + contentItem: Rectangle { implicitHeight: 1; color: theme.separator } + } + MenuItem { + text: "Halt scan without cleanup…" + enabled: root.hasCurrent + onTriggered: confirm.ask(moreButton, "Halt " + queue.currentLabel + " without cleanup?", + "Stops immediately and skips the scan's cleanup routines, so motors may stay where they are. Use Abort unless cleanup itself is the problem.", + "Halt scan", function () { queue.halt() }) + } + MenuItem { + text: "Clear queue…" + enabled: root.hasCurrent || queue.waitingCount > 0 + onTriggered: confirm.ask(moreButton, "Clear the queue?", + (root.hasCurrent ? "Stops " + queue.currentLabel + " and removes " : "Removes ") + + queue.waitingCount + " waiting scan" + (queue.waitingCount === 1 ? "" : "s") + ". This cannot be undone, and the queue is paused afterwards.", + "Clear queue", function () { queue.clear() }) + } + } + + // Banners: say why nothing is moving + Rectangle { + visible: root.locked || root.paused + Layout.fillWidth: true + Layout.leftMargin: 10 + Layout.rightMargin: 10 + Layout.bottomMargin: 8 + implicitHeight: bannerRow.implicitHeight + 16 + radius: 7 + color: root.locked ? theme.errTint : theme.warnTint + RowLayout { + id: bannerRow + anchors { fill: parent; margins: 8; leftMargin: 10 } + spacing: 10 + Icon { + name: root.locked ? "lock" : "pause_circle" + size: 20 + color: root.locked ? theme.errText : theme.warnText + Layout.alignment: Qt.AlignTop + } + ColumnLayout { + spacing: 1 + Layout.fillWidth: true + Text { + text: root.locked ? "Queue locked" : "Queue paused" + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + } + Text { + Layout.fillWidth: true + wrapMode: Text.WordWrap + color: theme.muted + font.pixelSize: 12 + text: root.locked + ? (queue.lockReason !== "" ? queue.lockReason + ". " : "") + "Scans start again when the lock is released." + : root.hasCurrent ? "The running scan holds at its next checkpoint; nothing new starts until you resume." + : "Nothing new starts until you resume." + } + } + TextButton { + visible: root.paused + text: "Resume" + iconName: "play_arrow" + onClicked: queue.resume() + } + } + } + + Rectangle { Layout.fillWidth: true; implicitHeight: 1; color: theme.separator; visible: queue.toolbarVisible } + + // Rows + ListView { + id: list + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: queue.model + boundsBehavior: Flickable.StopAtBounds + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } + keyNavigationEnabled: true + + section.property: "section" + section.delegate: Rectangle { + required property string section + width: ListView.view.width + implicitHeight: 26 + color: theme.bg + Text { + anchors { left: parent.left; leftMargin: 12; verticalCenter: parent.verticalCenter } + text: (section === "Recent" ? "Recently finished" : section).toUpperCase() + + (section === "Waiting" ? " · " + queue.waitingCount : "") + color: theme.faint + font.pixelSize: 11 + font.weight: Font.Bold + font.letterSpacing: 0.8 + } + } + + delegate: QueueRow { + width: ListView.view.width + onAbortClicked: (anchor) => root.askAbort(anchor) + } + + add: Transition { + NumberAnimation { property: "opacity"; from: 0; to: 1; duration: 180 } + NumberAnimation { property: "x"; from: -12; to: 0; duration: 180; easing.type: Easing.OutCubic } + } + remove: Transition { + NumberAnimation { property: "opacity"; to: 0; duration: 150 } + } + displaced: Transition { + NumberAnimation { properties: "y"; duration: 200; easing.type: Easing.OutCubic } + } + move: Transition { + NumberAnimation { properties: "y"; duration: 200; easing.type: Easing.OutCubic } + } + } + } + + // Empty state: teaches instead of showing a blank table + ColumnLayout { + anchors.centerIn: parent + anchors.verticalCenterOffset: queue.toolbarVisible ? 22 : 0 + visible: !root.hasCurrent && queue.waitingCount === 0 && queue.recentCount === 0 + spacing: 6 + Icon { + Layout.alignment: Qt.AlignHCenter + name: "playlist_add" + size: 30 + color: theme.faint + } + Text { + Layout.alignment: Qt.AlignHCenter + text: "Queue is empty" + color: theme.fg + font.pixelSize: 14 + font.weight: Font.DemiBold + } + Text { + Layout.alignment: Qt.AlignHCenter + text: queue.lastFinished !== "" ? "Last: " + queue.lastFinished + : "Scans you submit from Scan Control or the command line appear here." + color: theme.muted + font.pixelSize: 12 + } + } +} diff --git a/bec_widgets/widgets/services/bec_queue/qml/Icon.qml b/bec_widgets/widgets/services/bec_queue/qml/Icon.qml new file mode 100644 index 00000000..6e9f1247 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/Icon.qml @@ -0,0 +1,24 @@ +import QtQuick + +// A bec_qthemes Material icon, tinted with any theme colour. +Item { + id: root + property string name: "help" + property color color: theme.fg + property bool filled: true + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + anchors.fill: parent + sourceSize.width: root.size * 2 + sourceSize.height: root.size * 2 + fillMode: Image.PreserveAspectFit + smooth: true + source: root.name ? "image://material/" + root.name + "?color=" + + root.color.toString().substring(1, 7) + "&filled=" + (root.filled ? 1 : 0) + : "" + } +} diff --git a/bec_widgets/widgets/services/bec_queue/qml/IconButton.qml b/bec_widgets/widgets/services/bec_queue/qml/IconButton.qml new file mode 100644 index 00000000..216e8533 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/IconButton.qml @@ -0,0 +1,37 @@ +import QtQuick +import QtQuick.Controls.Basic + +// Square icon-only button. Always carries a tooltip: no unlabelled icons. +AbstractButton { + id: root + property string iconName + property string tip + property bool danger: false + property int iconSize: 18 + + implicitWidth: 28 + implicitHeight: 28 + focusPolicy: Qt.StrongFocus + hoverEnabled: true + Accessible.name: tip + Accessible.role: Accessible.Button + + ToolTip.visible: hovered && tip !== "" + ToolTip.delay: 450 + ToolTip.text: tip + + background: Rectangle { + radius: 6 + color: root.down ? theme.pressed : root.hovered ? theme.hover : "transparent" + border.width: root.visualFocus ? 2 : 0 + border.color: theme.primary + } + contentItem: Item { + Icon { + anchors.centerIn: parent + name: root.iconName + size: root.iconSize + color: !root.enabled ? theme.faint : root.danger ? theme.errText : theme.muted + } + } +} diff --git a/bec_widgets/widgets/services/bec_queue/qml/QueueRow.qml b/bec_widgets/widgets/services/bec_queue/qml/QueueRow.qml new file mode 100644 index 00000000..2688bf20 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/QueueRow.qml @@ -0,0 +1,235 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// One queue entry. Model roles arrive as required properties. +Rectangle { + id: row + required property int index + required property string section + required property string requestId + required property string scanId + required property string scanNumber + required property string title + required property string subtitle + required property string sample + required property string stateLabel + required property string tone + required property string icon + required property string details + required property bool canMoveUp + required property bool canMoveDown + required property real progress + required property string progressText + required property bool removing + + signal abortClicked(Item anchor) + + property bool confirmingRemove: false + + implicitHeight: 54 + color: hover.hovered ? theme.hover : "transparent" + opacity: removing ? 0.45 : section === "Recent" ? 0.82 : 1 + Behavior on opacity { NumberAnimation { duration: 150 } } + + HoverHandler { id: hover } + ToolTip.visible: hover.hovered && details !== "" && !confirmingRemove + ToolTip.delay: 700 + ToolTip.text: details + + Rectangle { + anchors { left: parent.left; right: parent.right; bottom: parent.bottom; leftMargin: 12; rightMargin: 12 } + height: 1 + color: theme.separator + } + + Timer { + id: confirmTimer + interval: 4000 + onTriggered: row.confirmingRemove = false + } + + RowLayout { + anchors { fill: parent; leftMargin: 12; rightMargin: 8 } + spacing: 10 + + Text { + Layout.preferredWidth: 44 + text: row.scanNumber !== "" ? row.scanNumber : "—" + color: row.scanNumber !== "" ? theme.fg : theme.faint + font.pixelSize: 13 + font.weight: Font.DemiBold + font.family: "monospace" + horizontalAlignment: Text.AlignRight + Accessible.name: row.scanNumber !== "" ? "Scan " + row.scanNumber : "No scan number yet" + } + + Item { + Layout.preferredWidth: 136 + Layout.fillHeight: true + StatusPill { + anchors.verticalCenter: parent.verticalCenter + label: row.removing ? "Removing…" : row.stateLabel + iconName: row.removing ? "hourglass_empty" : row.icon + tone: row.removing ? "stale" : row.tone + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.minimumWidth: 80 + spacing: 2 + RowLayout { + spacing: 6 + Layout.fillWidth: true + Text { + text: row.title + color: theme.fg + font.pixelSize: 13 + font.weight: Font.DemiBold + elide: Text.ElideRight + Layout.fillWidth: row.sample === "" + } + Rectangle { + visible: row.sample !== "" + implicitHeight: 18 + implicitWidth: sampleText.implicitWidth + 12 + radius: 4 + color: theme.field + border.width: 1 + border.color: theme.separator + Layout.maximumWidth: 180 + Text { + id: sampleText + anchors.centerIn: parent + width: Math.min(implicitWidth, 168) + elide: Text.ElideRight + text: row.sample + color: theme.muted + font.pixelSize: 11 + } + } + Item { Layout.fillWidth: row.sample !== "" } + } + Text { + visible: text !== "" + text: row.subtitle + color: theme.muted + font.pixelSize: 12 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + // Progress of the running scan + ColumnLayout { + visible: row.section === "Now" + Layout.preferredWidth: 190 + spacing: 4 + Rectangle { + Layout.fillWidth: true + implicitHeight: 5 + radius: 2.5 + color: theme.separator + clip: true + Rectangle { + visible: row.progress >= 0 + height: parent.height + radius: 2.5 + width: parent.width * Math.max(0, row.progress) + color: row.tone === "warn" ? theme.warn : theme.busy + Behavior on width { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } + } + Rectangle { // indeterminate: no progress reported yet + id: sweep + visible: row.progress < 0 && row.tone === "busy" + height: parent.height + width: parent.width * 0.3 + radius: 2.5 + color: theme.busy + opacity: 0.7 + NumberAnimation on x { + running: sweep.visible + loops: Animation.Infinite + from: -sweep.width + to: sweep.parent.width + duration: 1400 + easing.type: Easing.InOutSine + } + } + } + Text { + text: row.progressText !== "" ? row.progressText : row.tone === "busy" ? "Waiting for progress…" : "" + color: theme.muted + font.pixelSize: 11 + elide: Text.ElideRight + Layout.fillWidth: true + } + } + + // Actions + Item { + Layout.preferredWidth: 96 + Layout.fillHeight: true + + RowLayout { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + visible: !row.confirmingRemove + + IconButton { + visible: row.section === "Waiting" + iconName: "arrow_upward" + tip: "Move up" + enabled: row.canMoveUp && !row.removing + onClicked: queue.moveUp(row.scanId) + } + IconButton { + visible: row.section === "Waiting" + iconName: "arrow_downward" + tip: "Move down" + enabled: row.canMoveDown && !row.removing + onClicked: queue.moveDown(row.scanId) + } + IconButton { + visible: row.section === "Waiting" + iconName: "delete" + tip: "Remove this waiting scan from the queue" + danger: true + enabled: !row.removing && row.requestId !== "" + onClicked: { row.confirmingRemove = true; confirmTimer.restart(); keepButton.forceActiveFocus() } + } + IconButton { + id: abortButton + visible: row.section === "Now" + iconName: "stop_circle" + tip: "Abort " + (row.scanNumber !== "" ? "scan " + row.scanNumber : row.title) + "…" + danger: true + enabled: !row.removing && row.tone !== "ok" + onClicked: row.abortClicked(abortButton) + } + } + + // Inline two-step remove: no dialog, nothing removed on a single click + RowLayout { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + visible: row.confirmingRemove + spacing: 4 + TextButton { + id: keepButton + text: "Keep" + implicitHeight: 24 + onClicked: row.confirmingRemove = false + } + TextButton { + text: "Remove" + kind: "dangerSolid" + implicitHeight: 24 + onClicked: { row.confirmingRemove = false; queue.remove(row.requestId) } + } + } + } + } +} diff --git a/bec_widgets/widgets/services/bec_queue/qml/StatusPill.qml b/bec_widgets/widgets/services/bec_queue/qml/StatusPill.qml new file mode 100644 index 00000000..afd21951 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/StatusPill.qml @@ -0,0 +1,45 @@ +import QtQuick +import QtQuick.Layouts + +// Status shown as glyph + word; colour only for states that need attention. +// tone: neutral | busy | ok | warn | err | stale +Rectangle { + id: root + property string label + property string iconName + property string tone: "neutral" + + readonly property color textColor: tone === "busy" ? theme.busyText + : tone === "ok" ? theme.okText + : tone === "warn" ? theme.warnText + : tone === "err" ? theme.errText + : tone === "stale" ? theme.faint : theme.muted + readonly property color fillColor: tone === "busy" ? theme.busyTint + : tone === "warn" ? theme.warnTint + : tone === "err" ? theme.errTint : "transparent" + + implicitHeight: 22 + implicitWidth: row.implicitWidth + 14 + radius: height / 2 + color: fillColor + border.width: tone === "neutral" || tone === "stale" || tone === "ok" ? 1 : 0 + border.color: theme.separator + Behavior on color { ColorAnimation { duration: 150 } } + + RowLayout { + id: row + anchors.centerIn: parent + spacing: 4 + Icon { + name: root.iconName + size: 14 + color: root.textColor + } + Text { + text: root.label + color: root.textColor + font.pixelSize: 12 + font.weight: Font.DemiBold + } + } +} diff --git a/bec_widgets/widgets/services/bec_queue/qml/TextButton.qml b/bec_widgets/widgets/services/bec_queue/qml/TextButton.qml new file mode 100644 index 00000000..308e718d --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/qml/TextButton.qml @@ -0,0 +1,55 @@ +import QtQuick +import QtQuick.Controls.Basic +import QtQuick.Layouts + +// Labelled button. kind: "default", "danger" (red outline) or "dangerSolid". +Button { + id: root + property string iconName + property string kind: "default" + property string tip + + focusPolicy: Qt.StrongFocus + hoverEnabled: true + implicitHeight: 28 + leftPadding: iconName ? 8 : 12 + rightPadding: 12 + font.pixelSize: 12 + font.weight: Font.Medium + + ToolTip.visible: hovered && tip !== "" + ToolTip.delay: 450 + ToolTip.text: tip + + readonly property color fgColor: !enabled ? theme.faint + : kind === "dangerSolid" ? "white" + : kind === "danger" ? theme.errText : theme.fg + + background: Rectangle { + radius: 6 + color: root.kind === "dangerSolid" + ? (root.down ? Qt.darker(theme.err, 1.2) : root.hovered ? Qt.lighter(theme.err, 1.1) : theme.err) + : root.down ? theme.pressed : root.hovered ? theme.hover : "transparent" + border.width: root.visualFocus ? 2 : 1 + border.color: root.visualFocus ? theme.primary + : root.kind === "danger" && root.enabled ? Qt.alpha(theme.err, 0.55) + : root.kind === "dangerSolid" ? "transparent" : theme.border + Behavior on color { ColorAnimation { duration: 90 } } + } + contentItem: RowLayout { + spacing: 5 + Icon { + visible: root.iconName !== "" + name: root.iconName + size: 16 + color: root.fgColor + } + Text { + text: root.text + font: root.font + color: root.fgColor + elide: Text.ElideRight + Layout.fillWidth: true + } + } +} diff --git a/bec_widgets/widgets/services/bec_queue/queue_model.py b/bec_widgets/widgets/services/bec_queue/queue_model.py new file mode 100644 index 00000000..54a2f772 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/queue_model.py @@ -0,0 +1,514 @@ +""" +Data layer of the BEC queue widget: turns queue status, queue history and scan progress into rows +for the QML view, and exposes queue state and actions to QML through :class:`QueueController`. + +Nothing here talks to BEC directly; the widget shell feeds messages in and performs the requests +the controller emits. That keeps this module testable without a client. +""" + +from __future__ import annotations + +import json +import time +from dataclasses import asdict, dataclass, field, fields + +from bec_lib import messages +from qtpy.QtCore import ( + Property, + QAbstractListModel, + QByteArray, + QModelIndex, + QObject, + Qt, + Signal, + Slot, +) + +ACTIVE_STATUSES = ("RUNNING", "PAUSED", "DEFERRED_PAUSE") +WAITING_STATUSES = ("PENDING", "IDLE") +IDLE_STATUSES = ("STOPPED", "COMPLETED", "IDLE") + +# status -> (label, tone, material icon). Tones map to theme colours in QML; colour is only used +# for states that need attention, normal states stay neutral. +STATE_STYLES: dict[str, tuple[str, str, str]] = { + "RUNNING": ("Running", "busy", "play_arrow"), + "DEFERRED_PAUSE": ("Pausing", "warn", "pause"), + "PAUSED": ("Paused", "warn", "pause"), + "PENDING": ("Queued", "neutral", "schedule"), + "IDLE": ("Queued", "neutral", "schedule"), + "COMPLETED": ("Done", "ok", "check"), + "STOPPED": ("Stopped", "warn", "stop"), + "CANCELLED": ("Removed", "stale", "close"), +} +REASON_LABELS = {"user": "by user", "alarm": "alarm", "restart": "restarted"} + + +@dataclass +class QueueRow: # pylint: disable=too-many-instance-attributes + """One row of the queue view.""" + + key: str + section: str # "Now", "Waiting" or "Recent" + queue_id: str + request_id: str = "" + scan_id: str = "" + scan_number: str = "" + title: str = "" + subtitle: str = "" + sample: str = "" + status: str = "" + state_label: str = "" + tone: str = "neutral" + icon: str = "help" + details: str = "" + can_move_up: bool = False + can_move_down: bool = False + progress: float = -1.0 + progress_text: str = "" + removing: bool = False + extra: dict = field(default_factory=dict) + + +def _fmt_num(value) -> str: + if isinstance(value, float): + return f"{value:g}" + return str(value) + + +def describe_parameters(msg: messages.ScanQueueMessage) -> str: + """ + Summarise a scan request in one short line, e.g. ``samx −5 → 5 · 41 steps · 0.1 s``. + + Args: + msg(messages.ScanQueueMessage): The scan request. + + Returns: + str: The summary; empty if nothing useful can be said. + """ + parameter = msg.parameter or {} + args = parameter.get("args", {}) + kwargs = parameter.get("kwargs", {}) + parts: list[str] = [] + if isinstance(args, dict): + for device, values in list(args.items())[:3]: + if isinstance(values, (list, tuple)) and len(values) >= 2: + text = f"{device} {_fmt_num(values[0])} → {_fmt_num(values[1])}" + if len(values) >= 3: + text += f" ({_fmt_num(values[2])})" + parts.append(text.replace("-", "−")) + else: + parts.append(str(device)) + if "steps" in kwargs: + parts.append(f"{kwargs['steps']} steps") + elif "step" in kwargs: + parts.append(f"step {_fmt_num(kwargs['step'])}") + if "exp_time" in kwargs: + parts.append(f"{_fmt_num(kwargs['exp_time'])} s") + if kwargs.get("relative"): + parts.append("relative") + return " · ".join(parts) + + +def _fmt_duration(seconds: float) -> str: + seconds = max(0, int(round(seconds))) + if seconds >= 3600: + return f"{seconds // 3600}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}" + return f"{seconds // 60}:{seconds % 60:02d}" + + +@dataclass +class ProgressState: + """Latest progress of the running scan, as received from the scan_progress endpoint.""" + + scan_id: str | None = None + value: float = 0 + max_value: float = 0 + started: float = 0.0 + updated: float = 0.0 + + def fraction(self) -> float: + """Return the progress as a fraction, or -1 if unknown.""" + if self.max_value <= 0: + return -1.0 + return max(0.0, min(1.0, self.value / self.max_value)) + + def text(self) -> str: + """Return e.g. ``12 / 41 · 0:35 · ≈ 1:20 left``.""" + if self.max_value <= 0: + return "" + out = f"{int(self.value)} / {int(self.max_value)}" + elapsed = self.updated - self.started + if elapsed > 0: + out += f" · {_fmt_duration(elapsed)}" + if 0 < self.value < self.max_value: + remaining = elapsed / self.value * (self.max_value - self.value) + out += f" · ≈ {_fmt_duration(remaining)} left" + return out + + +def _entry_row( # pylint: disable=too-many-locals + entry: messages.QueueInfoEntry, section: str, status: str +) -> QueueRow: + titles, types, numbers, subtitles, samples, metadata, rids = [], [], [], [], [], [], [] + for block in entry.request_blocks: + msg = block.msg + user_metadata = msg.metadata.get("user_metadata", {}) or {} + types.append(msg.scan_type) + titles.append(user_metadata.get("scan_name") or msg.scan_type) + if block.scan_number is not None: + numbers.append(str(block.scan_number)) + if summary := describe_parameters(msg): + subtitles.append(summary) + if sample := user_metadata.get("sample_name"): + samples.append(str(sample)) + if user_metadata: + metadata.append(user_metadata) + if block.RID: + rids.append(block.RID) + + label, tone, icon = STATE_STYLES.get(status, (status.title() or "Unknown", "stale", "help")) + if status == "STOPPED" and entry.reason: + label = "Aborted" if entry.reason == "user" else "Stopped" + label += f" · {REASON_LABELS.get(entry.reason, entry.reason)}" + tone = "err" if entry.reason == "alarm" else "warn" + + title = ", ".join(dict.fromkeys(titles)) + subtitle_parts = [] + if title not in types: + subtitle_parts.append(", ".join(types)) + subtitle_parts.extend(subtitles) + details = "" + if metadata: + details = json.dumps(metadata[0] if len(metadata) == 1 else metadata, indent=2) + scan_ids = [sid for sid in entry.scan_id if sid] + return QueueRow( + key=f"{section}:{entry.queue_id}", + section=section, + queue_id=entry.queue_id, + request_id=rids[0] if rids else "", + scan_id=scan_ids[0] if scan_ids else "", + scan_number=", ".join(numbers), + title=title, + subtitle=" · ".join(subtitle_parts), + sample=", ".join(dict.fromkeys(samples)), + status=status, + state_label=label, + tone=tone, + icon=icon, + details=details, + ) + + +def build_rows( + queue: messages.ScanQueueStatus | None, + history: list[messages.ScanQueueHistoryMessage], + progress: ProgressState | None = None, + removing: set[str] | None = None, + max_recent: int = 5, +) -> list[QueueRow]: + """ + Build the rows of the queue view. + + Args: + queue(messages.ScanQueueStatus | None): The primary queue, if any. + history(list[messages.ScanQueueHistoryMessage]): Finished queue items, newest first. + progress(ProgressState | None): Progress of the running scan. + removing(set[str] | None): Request ids with a pending remove request. + max_recent(int): Number of history rows to show. + + Returns: + list[QueueRow]: Rows in display order: Now, Waiting, Recent. + """ + removing = removing or set() + now_rows: list[QueueRow] = [] + waiting_rows: list[QueueRow] = [] + in_queue: set[str] = set() + for entry in queue.info if queue else []: + in_queue.add(entry.queue_id) + status = entry.status + if status in WAITING_STATUSES: + waiting_rows.append(_entry_row(entry, "Waiting", status)) + else: + row = _entry_row(entry, "Now", status) + if progress and status in ACTIVE_STATUSES: + if progress.scan_id is None or progress.scan_id in entry.scan_id: + row.progress = progress.fraction() + row.progress_text = progress.text() + now_rows.append(row) + for index, row in enumerate(waiting_rows): + row.can_move_up = index > 0 and bool(row.scan_id) + row.can_move_down = index < len(waiting_rows) - 1 and bool(row.scan_id) + row.removing = row.request_id in removing + for row in now_rows: + row.removing = row.request_id in removing + + recent_rows: list[QueueRow] = [] + for msg in history: + if len(recent_rows) >= max_recent: + break + if msg.queue_id in in_queue: + continue + recent_rows.append(_entry_row(msg.info, "Recent", msg.status)) + return now_rows + waiting_rows + recent_rows + + +class QueueListModel(QAbstractListModel): + """ + List model of :class:`QueueRow` items. + + Updates are applied as inserts, moves and removals keyed by row, so QML can animate them and + keep hover and focus on rows that did not change. + """ + + ROLE_NAMES = [f.name for f in fields(QueueRow) if f.name != "extra"] + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._rows: list[QueueRow] = [] + self._roles = {Qt.ItemDataRole.UserRole + i: n for i, n in enumerate(self.ROLE_NAMES)} + + # pylint: disable=invalid-name + def rowCount(self, parent=QModelIndex()) -> int: + """Return the number of rows.""" + return 0 if parent.isValid() else len(self._rows) + + def roleNames(self) -> dict[int, QByteArray]: + """Return the QML role names: the QueueRow fields in camelCase.""" + return {role: QByteArray(_camel(name).encode()) for role, name in self._roles.items()} + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): + """Return the value of ``role`` for the row at ``index``.""" + if not index.isValid() or not 0 <= index.row() < len(self._rows): + return None + name = self._roles.get(role) + if name is None: + return None + return getattr(self._rows[index.row()], name) + + def rows(self) -> list[QueueRow]: + """Return a copy of the current rows.""" + return list(self._rows) + + def set_rows(self, new_rows: list[QueueRow]): + """ + Replace the rows with ``new_rows`` using minimal inserts, moves and removals. + + Args: + new_rows(list[QueueRow]): The rows to show. + """ + new_keys = {row.key for row in new_rows} + for i in reversed(range(len(self._rows))): + if self._rows[i].key not in new_keys: + self.beginRemoveRows(QModelIndex(), i, i) + del self._rows[i] + self.endRemoveRows() + for target, row in enumerate(new_rows): + current = [r.key for r in self._rows] + if target < len(current) and current[target] == row.key: + if asdict(self._rows[target]) != asdict(row): + self._rows[target] = row + idx = self.index(target) + self.dataChanged.emit(idx, idx) + continue + if row.key in current: + source = current.index(row.key) + self.beginMoveRows(QModelIndex(), source, source, QModelIndex(), target) + moved = self._rows.pop(source) + self._rows.insert(target, moved) + self.endMoveRows() + self._rows[target] = row + idx = self.index(target) + self.dataChanged.emit(idx, idx) + else: + self.beginInsertRows(QModelIndex(), target, target) + self._rows.insert(target, row) + self.endInsertRows() + + +def _camel(name: str) -> str: + head, *rest = name.split("_") + return head + "".join(part.title() for part in rest) + + +class QueueController(QObject): + """ + State and actions of the queue view, exposed to QML as ``queue``. + + The widget shell feeds it with :meth:`set_queue`, :meth:`set_history` and + :meth:`set_progress`, and connects the ``*_requested`` signals to BEC requests. + """ + + changed = Signal() + pause_requested = Signal() + resume_requested = Signal() + abort_requested = Signal(str) + halt_requested = Signal() + remove_requested = Signal(str) + move_requested = Signal(str, str) + clear_requested = Signal() + + def __init__(self, parent: QObject | None = None): + super().__init__(parent) + self._model = QueueListModel(self) + self._queue: messages.ScanQueueStatus | None = None + self._history: list[messages.ScanQueueHistoryMessage] = [] + self._progress = ProgressState() + self._removing: set[str] = set() + self._show_recent = True + self._toolbar_visible = True + + # ------------------------------------------------------------------ inputs + + def set_queue(self, queue: messages.ScanQueueStatus | None): + """Set the primary queue status. Clears pending removals: the server has answered.""" + self._queue = queue + self._removing.clear() + self._rebuild() + + def set_history(self, history: list[messages.ScanQueueHistoryMessage]): + """Set the queue history, newest first.""" + self._history = list(history) + self._rebuild() + + def set_progress(self, scan_id: str | None, value: float, max_value: float, done: bool): + """Update the progress of the running scan.""" + now = time.monotonic() + if scan_id != self._progress.scan_id or not self._progress.started: + self._progress = ProgressState(scan_id=scan_id, started=now) + self._progress.value = value + self._progress.max_value = max_value + self._progress.updated = now + if done: + self._progress = ProgressState() + self._rebuild() + + def _rebuild(self): + self._model.set_rows( + build_rows( + self._queue, + self._history if self._show_recent else [], + self._progress, + self._removing, + ) + ) + self.changed.emit() + + # ------------------------------------------------------------------ derived state + + def current_row(self) -> QueueRow | None: + """Return the row of the running scan, if any.""" + return next((r for r in self._model.rows() if r.section == "Now"), None) + + def _get_state(self) -> str: + if self._queue is None: + return "idle" + if self._queue.status == "LOCKED": + return "locked" + if self._queue.status == "PAUSED": + return "paused" + if any(e.status in ACTIVE_STATUSES for e in self._queue.info): + return "running" + return "idle" + + def _get_lock_reason(self) -> str: + if self._queue is None: + return "" + return "; ".join(lock.reason for lock in self._queue.locks if lock.reason) + + def _count(self, section: str) -> int: + return sum(1 for r in self._model.rows() if r.section == section) + + def _get_model(self) -> QueueListModel: + return self._model + + def _running_count(self) -> int: + return self._count("Now") + + def _waiting_count(self) -> int: + return self._count("Waiting") + + def _recent_count(self) -> int: + return self._count("Recent") + + def _current_label(self) -> str: + row = self.current_row() + if row is None: + return "" + return f"scan {row.scan_number}" if row.scan_number else row.title + + def _get_last_finished(self) -> str: + if not self._history: + return "" + last = _entry_row(self._history[0].info, "Recent", self._history[0].status) + name = f"scan {last.scan_number}" if last.scan_number else last.title + return f"{name} · {last.state_label.lower()}" + + def _get_show_recent(self) -> bool: + return self._show_recent + + def _set_show_recent(self, value: bool): + if value != self._show_recent: + self._show_recent = value + self._rebuild() + + def _get_toolbar_visible(self) -> bool: + return self._toolbar_visible + + def set_toolbar_visible(self, value: bool): + """Show or hide the header bar with the queue state and controls.""" + if value != self._toolbar_visible: + self._toolbar_visible = value + self.changed.emit() + + # pylint: disable=missing-function-docstring + model = Property(QObject, _get_model, constant=True) + state = Property(str, _get_state, notify=changed) + lockReason = Property(str, _get_lock_reason, notify=changed) + runningCount = Property(int, _running_count, notify=changed) + waitingCount = Property(int, _waiting_count, notify=changed) + recentCount = Property(int, _recent_count, notify=changed) + currentLabel = Property(str, _current_label, notify=changed) + lastFinished = Property(str, _get_last_finished, notify=changed) + showRecent = Property(bool, _get_show_recent, _set_show_recent, notify=changed) + toolbarVisible = Property(bool, _get_toolbar_visible, set_toolbar_visible, notify=changed) + + # ------------------------------------------------------------------ actions from QML + + @Slot() + def pause(self): + self.pause_requested.emit() + + @Slot() + def resume(self): + self.resume_requested.emit() + + @Slot() + def abortCurrent(self): # pylint: disable=invalid-name + row = self.current_row() + self.abort_requested.emit(row.request_id if row else "") + + @Slot() + def halt(self): + self.halt_requested.emit() + + @Slot(str) + def remove(self, request_id: str): + if not request_id: + return + self._removing.add(request_id) + self._rebuild() + self.remove_requested.emit(request_id) + + @Slot(str) + def moveUp(self, scan_id: str): # pylint: disable=invalid-name + if scan_id: + self.move_requested.emit(scan_id, "move_up") + + @Slot(str) + def moveDown(self, scan_id: str): # pylint: disable=invalid-name + if scan_id: + self.move_requested.emit(scan_id, "move_down") + + @Slot() + def clear(self): + self.clear_requested.emit() diff --git a/tests/unit_tests/test_bec_queue.py b/tests/unit_tests/test_bec_queue.py index f03cfc52..3a09be99 100644 --- a/tests/unit_tests/test_bec_queue.py +++ b/tests/unit_tests/test_bec_queue.py @@ -1,147 +1,264 @@ +import uuid +from unittest import mock + import pytest from bec_lib import messages +from bec_lib.endpoints import MessageEndpoints +from qtpy.QtQuickWidgets import QQuickWidget from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.tests.utils import create_widget from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue +from bec_widgets.widgets.services.bec_queue.queue_model import ( + ProgressState, + QueueListModel, + QueueRow, + build_rows, + describe_parameters, +) + + +def _block(scan_type="line_scan", scan_number=None, user_metadata=None, args=None, kwargs=None): + rid = str(uuid.uuid4()) + return { + "msg": messages.ScanQueueMessage( + scan_type=scan_type, + parameter={ + "args": args if args is not None else {"samx": [-0.1, 0.1]}, + "kwargs": kwargs if kwargs is not None else {"steps": 20, "exp_time": 0.5}, + }, + queue="primary", + metadata={"RID": rid, "user_metadata": user_metadata or {}}, + ), + "RID": rid, + "readout_priority": {"monitored": ["samx"], "baseline": [], "async": [], "on_request": []}, + "is_scan": True, + "scan_number": scan_number, + "scan_id": str(uuid.uuid4()), + } + + +def _entry(status, scan_number=None, reason=None, **kwargs): + block = _block(scan_number=scan_number, **kwargs) + return messages.QueueInfoEntry( + queue_id=str(uuid.uuid4()), + scan_id=[block["scan_id"]], + is_scan=[True], + request_blocks=[block], + scan_number=[scan_number], + status=status, + reason=reason, + ) + + +def _status_msg(entries, status="RUNNING", locks=None): + return messages.ScanQueueStatusMessage( + metadata={}, queue={"primary": {"info": entries, "status": status, "locks": locks or []}} + ) @pytest.fixture def bec_queue_msg_full(): - content = { - "primary": { - "info": [ - { - "active_request_block": None, - "is_scan": [True], - "queue_id": "600163fc-5e56-4901-af25-14e9ee76817c", - "request_blocks": [ - { - "RID": "89a76021-28c0-4297-828e-74ae40b941e5", - "content": { - "parameter": { - "args": {"samx": [-0.1, 0.1]}, - "kwargs": { - "exp_time": 0.5, - "relative": True, - "steps": 20, - "system_config": { - "file_directory": None, - "file_suffix": None, - }, - }, - }, - "queue": "primary", - "scan_type": "line_scan", - }, - "is_scan": True, - "metadata": { - "RID": "89a76021-28c0-4297-828e-74ae40b941e5", - "file_directory": None, - "file_suffix": None, - "user_metadata": {"sample_name": "testA"}, - }, - "msg": messages.ScanQueueMessage( - metadata={ - "file_suffix": None, - "file_directory": None, - "user_metadata": {"sample_name": "testA"}, - "RID": "89a76021-28c0-4297-828e-74ae40b941e5", - }, - scan_type="line_scan", - parameter={ - "args": {"samx": [-0.1, 0.1]}, - "kwargs": { - "steps": 20, - "exp_time": 0.5, - "relative": True, - "system_config": { - "file_suffix": None, - "file_directory": None, - }, - }, - }, - queue="primary", - ), - "readout_priority": { - "async": [], - "baseline": [], - "monitored": ["samx"], - "on_request": [], - }, - "report_instructions": [{"scan_progress": 20}], - "scan_id": "2d704cc3-c172-404c-866d-608ce09fce40", - "scan_number": 1289, - } - ], - "scan_id": ["2d704cc3-c172-404c-866d-608ce09fce40"], - "scan_number": [1289], - "status": "COMPLETED", - } - ], - "status": "RUNNING", - } - } - msg = messages.ScanQueueStatusMessage(metadata={}, queue=content) - return msg + entry = _entry("COMPLETED", scan_number=1289, user_metadata={"sample_name": "testA"}) + return _status_msg([entry]) + + +@pytest.fixture +def busy_queue(): + """One running scan and three waiting ones.""" + entries = [_entry("RUNNING", scan_number=1291, user_metadata={"sample_name": "LaB6"})] + entries += [_entry("PENDING") for _ in range(3)] + return _status_msg(entries) @pytest.fixture def bec_queue(qtbot, mocked_client): - widget = BECQueue(client=mocked_client, refresh_upon_start=False) - qtbot.addWidget(widget) + widget = create_widget(qtbot, BECQueue, client=mocked_client, refresh_upon_start=False) qtbot.waitExposed(widget) yield widget +def _rows(widget: BECQueue) -> list[QueueRow]: + return widget.controller.model.rows() + + +def test_bec_queue_qml_loads(bec_queue): + assert bec_queue.view.status() == QQuickWidget.Status.Ready + assert not bec_queue.view.errors() + + def test_bec_queue(bec_queue, bec_queue_msg_full): bec_queue.update_queue(bec_queue_msg_full.content, {}) - assert bec_queue.table.rowCount() == 1 - assert bec_queue.table.item(0, 0).text() == "1289" - assert bec_queue.table.item(0, 1).text() == "line_scan" - assert bec_queue.table.item(0, 2).text() == "line_scan" - assert bec_queue.table.item(0, 3).text() == "COMPLETED" + rows = _rows(bec_queue) + assert len(rows) == 1 + assert rows[0].scan_number == "1289" + assert rows[0].title == "line_scan" + assert rows[0].sample == "testA" + assert rows[0].state_label == "Done" + assert rows[0].subtitle == "samx −0.1 → 0.1 · 20 steps · 0.5 s" -def test_bec_queue_empty(bec_queue): +def test_bec_queue_empty(bec_queue, bec_queue_msg_full): + bec_queue.update_queue(bec_queue_msg_full.content, {}) bec_queue.update_queue({}, {}) - assert bec_queue.table.rowCount() == 1 - assert bec_queue.table.item(0, 0).text() == "" - assert bec_queue.table.item(0, 1).text() == "" - assert bec_queue.table.item(0, 2).text() == "" - assert bec_queue.table.item(0, 3).text() == "" + assert _rows(bec_queue) == [] + assert bec_queue.controller.state == "idle" -def test_queue_abort(bec_queue, bec_queue_msg_full): - # test if abort buttons are not leaking, so making 3 times the same abort request and fixture should check the leak of the buttons automatically +def test_bec_queue_sections_and_busy(qtbot, bec_queue, busy_queue): + with qtbot.waitSignal(bec_queue.queue_busy) as blocker: + bec_queue.update_queue(busy_queue.content, {}) + assert blocker.args == [True] + rows = _rows(bec_queue) + assert [r.section for r in rows] == ["Now", "Waiting", "Waiting", "Waiting"] + assert bec_queue.controller.state == "running" + assert bec_queue.controller.runningCount == 1 + assert bec_queue.controller.waitingCount == 3 + waiting = rows[1:] + assert [(r.can_move_up, r.can_move_down) for r in waiting] == [ + (False, True), + (True, True), + (True, False), + ] - bec_queue.update_queue(bec_queue_msg_full.content, {}) - assert bec_queue.table.rowCount() == 1 - assert bec_queue.table.item(0, 0).text() == "1289" - assert bec_queue.table.item(0, 1).text() == "line_scan" - assert bec_queue.table.item(0, 2).text() == "line_scan" - assert bec_queue.table.item(0, 3).text() == "COMPLETED" - abort_button = bec_queue.table.cellWidget(0, 4) - abort_button.button.click() +def test_bec_queue_paused_and_locked_state(bec_queue, busy_queue): + info = busy_queue.queue["primary"].info + bec_queue.update_queue(_status_msg(info, status="PAUSED").content, {}) + assert bec_queue.controller.state == "paused" + lock = messages.ScanQueueLock(reason="Ring current low", identifier="beam") + bec_queue.update_queue(_status_msg(info, status="LOCKED", locks=[lock]).content, {}) + assert bec_queue.controller.state == "locked" + assert bec_queue.controller.lockReason == "Ring current low" - bec_queue.update_queue(bec_queue_msg_full.content, {}) - assert bec_queue.table.rowCount() == 1 - assert bec_queue.table.item(0, 0).text() == "1289" - assert bec_queue.table.item(0, 1).text() == "line_scan" - assert bec_queue.table.item(0, 2).text() == "line_scan" - assert bec_queue.table.item(0, 3).text() == "COMPLETED" +def test_remove_marks_only_the_target_row(bec_queue, busy_queue): + """Regression: removing the third row used to hide the first (running) row.""" + bec_queue.update_queue(busy_queue.content, {}) + target = _rows(bec_queue)[3] + bec_queue.controller.remove(target.request_id) - abort_button = bec_queue.table.cellWidget(0, 4) - abort_button.button.click() + bec_queue.client.queue.request_scan_abortion.assert_called_once_with( + request_id=target.request_id + ) + rows = _rows(bec_queue) + assert len(rows) == 4, "rows only disappear once the server confirms" + assert [r.removing for r in rows] == [False, False, False, True] - bec_queue.update_queue(bec_queue_msg_full.content, {}) + # the server answers with the item gone + bec_queue.update_queue(_status_msg(busy_queue.queue["primary"].info[:3]).content, {}) + rows = _rows(bec_queue) + assert len(rows) == 3 + assert not any(r.removing for r in rows) - assert bec_queue.table.rowCount() == 1 - assert bec_queue.table.item(0, 0).text() == "1289" - assert bec_queue.table.item(0, 1).text() == "line_scan" - assert bec_queue.table.item(0, 2).text() == "line_scan" - assert bec_queue.table.item(0, 3).text() == "COMPLETED" - abort_button = bec_queue.table.cellWidget(0, 4) - abort_button.button.click() +def test_abort_current(bec_queue, busy_queue): + bec_queue.update_queue(busy_queue.content, {}) + running = _rows(bec_queue)[0] + bec_queue.controller.abortCurrent() + bec_queue.client.queue.request_scan_abortion.assert_called_once_with( + request_id=running.request_id + ) + + +def test_move_requests(bec_queue, busy_queue): + bec_queue.update_queue(busy_queue.content, {}) + row = _rows(bec_queue)[2] + bec_queue.controller.moveUp(row.scan_id) + bec_queue.controller.moveDown(row.scan_id) + calls = bec_queue.client.queue.request_queue_order_modification.call_args_list + assert calls == [ + mock.call(scan_id=row.scan_id, action="move_up"), + mock.call(scan_id=row.scan_id, action="move_down"), + ] + + +def test_pause_and_resume(bec_queue): + with mock.patch.object(bec_queue.client.connector, "send") as send: + bec_queue.controller.pause() + endpoint, msg = send.call_args.args + assert endpoint == MessageEndpoints.scan_queue_modification_request() + assert msg.action == "deferred_pause" + bec_queue.controller.resume() + bec_queue.client.queue.request_scan_continuation.assert_called_once() + + +def test_halt_and_clear(bec_queue): + bec_queue.controller.halt() + bec_queue.client.queue.request_scan_halt.assert_called_once() + bec_queue.controller.clear() + bec_queue.client.queue.request_queue_reset.assert_called_once() + + +def test_progress_is_shown_on_running_row(bec_queue, busy_queue): + bec_queue.update_queue(busy_queue.content, {}) + scan_id = busy_queue.queue["primary"].info[0].scan_id[0] + bec_queue.on_scan_progress({"value": 10, "max_value": 40, "done": False}, {"scan_id": scan_id}) + running = _rows(bec_queue)[0] + assert running.progress == pytest.approx(0.25) + assert running.progress_text.startswith("10 / 40") + bec_queue.on_scan_progress({"value": 40, "max_value": 40, "done": True}, {"scan_id": scan_id}) + assert _rows(bec_queue)[0].progress == -1 + + +def test_history_is_shown_as_recent(qtbot, bec_queue, busy_queue): + finished = _entry("STOPPED", scan_number=1290, reason="user") + bec_queue.client.connector.lpush( + MessageEndpoints.scan_queue_history(), + messages.ScanQueueHistoryMessage( + status="STOPPED", queue_id=finished.queue_id, info=finished + ), + ) + bec_queue.update_queue(busy_queue.content, {}) + # an item leaving the queue triggers a history read + bec_queue.update_queue(_status_msg(busy_queue.queue["primary"].info[1:]).content, {}) + qtbot.waitUntil(lambda: any(r.section == "Recent" for r in _rows(bec_queue)), timeout=2000) + recent = [r for r in _rows(bec_queue) if r.section == "Recent"] + assert recent[0].scan_number == "1290" + assert recent[0].state_label == "Aborted · by user" + assert bec_queue.controller.lastFinished == "scan 1290 · aborted · by user" + + bec_queue.controller.showRecent = False + assert not any(r.section == "Recent" for r in _rows(bec_queue)) + + +def test_hide_toolbar(bec_queue): + bec_queue.hide_toolbar = True + assert bec_queue.controller.toolbarVisible is False + bec_queue.hide_toolbar = False + assert bec_queue.controller.toolbarVisible is True + + +def test_describe_parameters_grid(): + msg = messages.ScanQueueMessage( + scan_type="grid_scan", + parameter={ + "args": {"samx": [-5, 5, 41], "samy": [-2, 2, 11]}, + "kwargs": {"exp_time": 0.1, "relative": True}, + }, + queue="primary", + metadata={}, + ) + assert describe_parameters(msg) == "samx −5 → 5 (41) · samy −2 → 2 (11) · 0.1 s · relative" + + +def test_build_rows_skips_history_still_in_queue(): + entry = _entry("RUNNING", scan_number=5) + queue = messages.ScanQueueStatus(info=[entry], status="RUNNING") + history = [ + messages.ScanQueueHistoryMessage(status="RUNNING", queue_id=entry.queue_id, info=entry) + ] + rows = build_rows(queue, history, ProgressState()) + assert [r.section for r in rows] == ["Now"] + + +def test_list_model_moves_rows_instead_of_resetting(qtbot): + model = QueueListModel() + rows = [QueueRow(key=k, section="Waiting", queue_id=k) for k in "abc"] + model.set_rows(rows) + with qtbot.assertNotEmitted(model.modelReset), qtbot.waitSignal(model.rowsMoved): + model.set_rows([rows[1], rows[0], rows[2]]) + assert [r.key for r in model.rows()] == ["b", "a", "c"] + with qtbot.waitSignal(model.rowsRemoved): + model.set_rows([rows[0]]) + assert [r.key for r in model.rows()] == ["a"] diff --git a/tests/unit_tests/test_bec_widget_cell_widgets.py b/tests/unit_tests/test_bec_widget_cell_widgets.py index 54d1b376..5828ce90 100644 --- a/tests/unit_tests/test_bec_widget_cell_widgets.py +++ b/tests/unit_tests/test_bec_widget_cell_widgets.py @@ -30,7 +30,6 @@ from qtpy.QtWidgets import ( from bec_widgets.tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree from bec_widgets.widgets.plots.waveform.waveform import Waveform from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue @@ -174,20 +173,19 @@ def test_inner_nested_view_close_releases_cell_widgets(qtbot, mocked_client): @inner_only def test_inner_bec_queue_close_then_relayout(qtbot, mocked_client): + """The queue renders its rows in QML (no cell widgets); closing must release the QML tree.""" queue = BECQueue(client=mocked_client, refresh_upon_start=False) qtbot.addWidget(queue) qtbot.waitExposed(queue) queue.update_queue(_three_row_queue_content(), {}) - assert queue.table.rowCount() == 3 - buttons = [queue.table.cellWidget(row, 4) for row in range(3)] - assert all(isinstance(button, AbortButton) for button in buttons) + assert len(queue.controller.model.rows()) == 3 + view = queue.view queue.close() _flush_deferred_deletes() - queue.table.updateEditorGeometries() # SIGSEGV on the deleted abort buttons + queue.update_queue(_three_row_queue_content(), {}) # late message after close - assert not any(shiboken6.isValid(button) for button in buttons) - assert all(queue.table.cellWidget(row, 4) is None for row in range(3)) + assert not shiboken6.isValid(view) or view.rootObject() is None @inner_only From 018e5051b7a3c606ee4dc8893b1d8bf309e6f53c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 20:13:54 +0000 Subject: [PATCH 2/2] feat(bec_queue): add QWidget view with the same UX for comparison with QML Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01F2iSCsSSznEdY4HxoVaqoi --- .../widgets/services/bec_queue/bec_queue.py | 14 +- .../services/bec_queue/bec_queue_qt.py | 753 ++++++++++++++++++ tests/unit_tests/test_bec_queue_qt.py | 54 ++ 3 files changed, 818 insertions(+), 3 deletions(-) create mode 100644 bec_widgets/widgets/services/bec_queue/bec_queue_qt.py create mode 100644 tests/unit_tests/test_bec_queue_qt.py diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue.py b/bec_widgets/widgets/services/bec_queue/bec_queue.py index 28ff1155..e1af3b15 100644 --- a/bec_widgets/widgets/services/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/services/bec_queue/bec_queue.py @@ -66,7 +66,7 @@ class BECQueue(BECWidget, CompactPopupWidget): self.controller.clear_requested.connect(self.clear_queue) self._history_ready.connect(self.controller.set_history) - self.view = create_quick_widget(self, QML_FILE, {"queue": self.controller}) + self.view = self._create_view() self.view.setMinimumSize(320, 160) self.addWidget(self.view) self.label = "BEC Queue" @@ -77,6 +77,14 @@ class BECQueue(BECWidget, CompactPopupWidget): if refresh_upon_start: self.refresh_queue() + def _create_view(self) -> QWidget: + """Create the widget that renders the queue: here the QML view.""" + return create_quick_widget(self, QML_FILE, {"queue": self.controller}) + + def _release_view(self): + """Unload the QML tree before the controller it binds to is deleted.""" + self.view.setSource(QUrl()) + @Property(bool) def hide_toolbar(self): """Property to hide the BEC Queue toolbar.""" @@ -223,10 +231,10 @@ class BECQueue(BECWidget, CompactPopupWidget): self.queue.request_queue_reset() def cleanup(self): - """Release the QML view before the controller it binds to.""" + """Wait for a pending history read and release the view.""" if self._history_thread is not None: self._history_thread.join(timeout=1) - self.view.setSource(QUrl()) + self._release_view() super().cleanup() diff --git a/bec_widgets/widgets/services/bec_queue/bec_queue_qt.py b/bec_widgets/widgets/services/bec_queue/bec_queue_qt.py new file mode 100644 index 00000000..26d2eea1 --- /dev/null +++ b/bec_widgets/widgets/services/bec_queue/bec_queue_qt.py @@ -0,0 +1,753 @@ +""" +QWidget rendering of the BEC queue, with the same UX as the QML view. + +This exists to compare the two toolkits on the same data layer (:class:`QueueController`) and the +same shell (:class:`BECQueue`); only the view differs. It is not registered for RPC or Designer. +""" + +from __future__ import annotations + +from bec_qthemes import material_icon +from qtpy.QtCore import QEvent, QPoint, QSize, Qt, QTimer +from qtpy.QtWidgets import ( + QFrame, + QHBoxLayout, + QLabel, + QMenu, + QProgressBar, + QPushButton, + QScrollArea, + QSizePolicy, + QStackedWidget, + QToolButton, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.eliding_label import ElidingLabel +from bec_widgets.utils.qml_host import QmlTheme +from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue +from bec_widgets.widgets.services.bec_queue.queue_model import QueueController, QueueRow + +TONE_KEYS = { + "busy": ("busyText", "busyTint"), + "ok": ("okText", None), + "warn": ("warnText", "warnTint"), + "err": ("errText", "errTint"), + "stale": ("faint", None), + "neutral": ("muted", None), +} + + +class _FitLabel(ElidingLabel): + """Eliding label that asks for its full text width, so it only shrinks when space runs out.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + """Width of the full, unelided text.""" + hint = super().sizeHint() + return QSize(self.fontMetrics().horizontalAdvance(self.text()) + 2, hint.height()) + + +def _hex(color) -> str: + return color.name() + + +def _icon_button(icon: str, tip: str, theme: QmlTheme, danger: bool = False) -> QToolButton: + button = QToolButton() + button.setObjectName("iconButton") + button.setToolTip(tip) + button.setAccessibleName(tip) + button.setAutoRaise(True) + button.setFixedSize(28, 28) + button.setIconSize(QSize(18, 18)) + button.setProperty("iconName", icon) + button.setProperty("danger", danger) + _refresh_icon(button, theme) + return button + + +def _refresh_icon(button, theme: QmlTheme, color=None): + name = button.property("iconName") + if not name: + return + if color is None: + color = theme.errText if button.property("danger") else theme.muted + button.setIcon(material_icon(name, color=_hex(color), filled=True, convert_to_pixmap=False)) + + +class StatusPill(QFrame): + """Glyph + word, coloured only for states that need attention.""" + + def __init__(self, theme: QmlTheme, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("pill") + self._theme = theme + self._icon = QLabel(self) + self._text = QLabel(self) + layout = QHBoxLayout(self) + layout.setContentsMargins(7, 2, 8, 2) + layout.setSpacing(4) + layout.addWidget(self._icon) + layout.addWidget(self._text) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self.setFixedHeight(22) + self._state = ("", "help", "neutral") + + def set_state(self, label: str, icon: str, tone: str): + """Show ``label`` with ``icon`` in the colours of ``tone``.""" + if (label, icon, tone) == self._state and self._text.text(): + return + self._state = (label, icon, tone) + text_key, tint_key = TONE_KEYS.get(tone, TONE_KEYS["neutral"]) + fg = getattr(self._theme, text_key) + fill = _hex(getattr(self._theme, tint_key)) if tint_key else "transparent" + border = "none" if tint_key else f"1px solid {_hex(self._theme.separator)}" + self.setStyleSheet( + f"#pill {{ background: {fill}; border: {border}; border-radius: 11px; }}" + f"#pill QLabel {{ color: {_hex(fg)}; font-size: 11px; font-weight: 600;" + " background: transparent; border: none; }" + ) + self._icon.setPixmap(material_icon(icon, size=(14, 14), color=_hex(fg), filled=True)) + self._text.setText(label) + self.setAccessibleName(label) + + def refresh(self): + """Re-apply the colours after a theme change.""" + label, icon, tone = self._state + self._state = ("", "", "") + self.set_state(label, icon, tone) + + +class ConfirmPopup(QFrame): + """Small popover with a title, an explanation and Cancel / confirm buttons.""" + + def __init__(self, parent: QWidget): + super().__init__(parent, Qt.WindowType.Popup) + self.setObjectName("confirm") + self.setFixedWidth(320) + self._title = QLabel(self) + self._title.setObjectName("confirmTitle") + self._title.setWordWrap(True) + self._body = QLabel(self) + self._body.setObjectName("muted") + self._body.setWordWrap(True) + self._cancel = QPushButton("Cancel", self) + self._ok = QPushButton(self) + self._ok.setObjectName("dangerSolid") + self._cancel.clicked.connect(self.close) + self._ok.clicked.connect(self._accept) + buttons = QHBoxLayout() + buttons.addStretch(1) + buttons.addWidget(self._cancel) + buttons.addWidget(self._ok) + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 14, 14, 14) + layout.setSpacing(8) + layout.addWidget(self._title) + layout.addWidget(self._body) + layout.addLayout(buttons) + self._action = None + + def ask(self, anchor: QWidget, title: str, body: str, confirm: str, action): + """Open below ``anchor``; run ``action`` if the user confirms.""" + self._title.setText(title) + self._body.setText(body) + self._ok.setText(confirm) + self._action = action + self.adjustSize() + pos = anchor.mapToGlobal(QPoint(anchor.width() - self.width(), anchor.height() + 6)) + self.move(pos) + self.show() + self._cancel.setFocus() + + def _accept(self): + self.close() + if self._action: + self._action() + + +class QueueRowWidget(QFrame): + """One queue entry.""" + + def __init__(self, view: "QueueView", row: QueueRow): + super().__init__(view) + self.setObjectName("row") + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + self.setFixedHeight(54) + self._view = view + theme = view.theme + self.row = row + + self.number = QLabel(self) + self.number.setObjectName("number") + self.number.setFixedWidth(44) + self.number.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + self.pill = StatusPill(theme, self) + pill_box = QWidget(self) + pill_box.setFixedWidth(136) + QHBoxLayout(pill_box).setContentsMargins(0, 0, 0, 0) + pill_box.layout().addWidget(self.pill) + pill_box.layout().addStretch(1) + + self.title = _FitLabel(self) + self.title.setObjectName("title") + self.sample = QLabel(self) + self.sample.setObjectName("sample") + self.sample.setFixedHeight(18) + self.subtitle = ElidingLabel(self) + self.subtitle.setObjectName("muted") + title_row = QHBoxLayout() + title_row.setSpacing(6) + title_row.addWidget(self.title) + title_row.addWidget(self.sample) + title_row.addStretch(1) + text = QVBoxLayout() + text.setSpacing(2) + text.addLayout(title_row) + text.addWidget(self.subtitle) + + # progress (running scan) + self.progress_box = QWidget(self) + self.progress_box.setFixedWidth(190) + self.progress = QProgressBar(self.progress_box) + self.progress.setTextVisible(False) + self.progress.setFixedHeight(5) + self.progress_text = QLabel(self.progress_box) + self.progress_text.setObjectName("small") + progress_layout = QVBoxLayout(self.progress_box) + progress_layout.setContentsMargins(0, 0, 0, 0) + progress_layout.setSpacing(4) + progress_layout.addStretch(1) + progress_layout.addWidget(self.progress) + progress_layout.addWidget(self.progress_text) + progress_layout.addStretch(1) + + # actions, with an inline Keep / Remove step + self.actions = QStackedWidget(self) + self.actions.setFixedWidth(130) + normal = QWidget() + normal_layout = QHBoxLayout(normal) + normal_layout.setContentsMargins(0, 0, 0, 0) + normal_layout.setSpacing(0) + normal_layout.addStretch(1) + self.up = _icon_button("arrow_upward", "Move up", theme) + self.down = _icon_button("arrow_downward", "Move down", theme) + self.remove = _icon_button( + "delete", "Remove this waiting scan from the queue", theme, danger=True + ) + self.abort = _icon_button("stop_circle", "Abort…", theme, danger=True) + for button in (self.up, self.down, self.remove, self.abort): + normal_layout.addWidget(button) + confirm = QWidget() + confirm_layout = QHBoxLayout(confirm) + confirm_layout.setContentsMargins(0, 0, 0, 0) + confirm_layout.setSpacing(4) + confirm_layout.addStretch(1) + self.keep = QPushButton("Keep") + self.keep.setObjectName("small") + self.confirm_remove = QPushButton("Remove") + self.confirm_remove.setObjectName("dangerSolidSmall") + confirm_layout.addWidget(self.keep) + confirm_layout.addWidget(self.confirm_remove) + self.actions.addWidget(normal) + self.actions.addWidget(confirm) + self._confirm_timer = QTimer(self) + self._confirm_timer.setSingleShot(True) + self._confirm_timer.setInterval(4000) + self._confirm_timer.timeout.connect(lambda: self.actions.setCurrentIndex(0)) + + controller = view.controller + self.up.clicked.connect(lambda: controller.moveUp(self.row.scan_id)) + self.down.clicked.connect(lambda: controller.moveDown(self.row.scan_id)) + self.remove.clicked.connect(self._start_remove) + self.keep.clicked.connect(lambda: self.actions.setCurrentIndex(0)) + self.confirm_remove.clicked.connect(self._remove) + self.abort.clicked.connect(lambda: view.ask_abort(self.abort)) + + layout = QHBoxLayout(self) + layout.setContentsMargins(12, 0, 8, 0) + layout.setSpacing(10) + layout.addWidget(self.number) + layout.addWidget(pill_box) + layout.addLayout(text, 1) + layout.addWidget(self.progress_box) + layout.addWidget(self.actions) + self.set_row(row) + + def _start_remove(self): + self.actions.setCurrentIndex(1) + self._confirm_timer.start() + self.keep.setFocus() + + def _remove(self): + self.actions.setCurrentIndex(0) + self._view.controller.remove(self.row.request_id) + + def set_row(self, row: QueueRow): + """Show ``row``.""" + if row == self.row and self.number.text(): + return + self.row = row + self.number.setText(row.scan_number or "—") + self.number.setProperty("empty", not row.scan_number) + if row.removing: + self.pill.set_state("Removing…", "hourglass_empty", "stale") + else: + self.pill.set_state(row.state_label, row.icon, row.tone) + self.title.setText(row.title) + self.sample.setText(row.sample) + self.sample.setVisible(bool(row.sample)) + self.subtitle.setText(row.subtitle) + self.subtitle.setVisible(bool(row.subtitle)) + self.setToolTip(row.details) + running = row.section == "Now" + self.progress_box.setVisible(running) + if running: + if row.progress >= 0: + self.progress.setRange(0, 1000) + self.progress.setValue(int(row.progress * 1000)) + else: + self.progress.setRange(0, 0) # busy indicator + self.progress_text.setText( + row.progress_text or ("Waiting for progress…" if row.tone == "busy" else "") + ) + waiting = row.section == "Waiting" + self.up.setVisible(waiting) + self.down.setVisible(waiting) + self.remove.setVisible(waiting) + self.up.setEnabled(row.can_move_up and not row.removing) + self.down.setEnabled(row.can_move_down and not row.removing) + self.remove.setEnabled(bool(row.request_id) and not row.removing) + self.abort.setVisible(running) + self.abort.setEnabled(not row.removing and row.tone != "ok") + label = f"scan {row.scan_number}" if row.scan_number else row.title + self.abort.setToolTip(f"Abort {label}…") + self.setProperty("recent", row.section == "Recent") + self.setProperty("removing", row.removing) + self.style().unpolish(self) + self.style().polish(self) + + def refresh_theme(self): + """Re-apply theme-dependent icons.""" + self.pill.refresh() + for button in (self.up, self.down, self.remove, self.abort): + _refresh_icon(button, self._view.theme) + + +class QueueView(QWidget): + """The queue rendered with QWidgets, driven by a :class:`QueueController`.""" + + def __init__(self, controller: QueueController, parent: QWidget | None = None): + super().__init__(parent) + self.setObjectName("queueView") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.controller = controller + self.theme = QmlTheme(self) + self._rows: dict[str, QueueRowWidget] = {} + self._section_labels: dict[str, QLabel] = {} + self.popup = ConfirmPopup(self) + + # header + self.header = QWidget(self) + self.state_pill = StatusPill(self.theme, self.header) + self.counts = QLabel(self.header) + self.counts.setObjectName("muted") + self.pause_button = QPushButton(self.header) + self.abort_button = QPushButton("Abort scan", self.header) + self.abort_button.setObjectName("danger") + self.more = _icon_button("more_horiz", "More queue actions", self.theme) + self.more.setParent(self.header) + self.menu = QMenu(self) + self.recent_action = self.menu.addAction("Show recent scans") + self.recent_action.setCheckable(True) + self.menu.addSeparator() + self.halt_action = self.menu.addAction("Halt scan without cleanup…") + self.clear_action = self.menu.addAction("Clear queue…") + self.more.clicked.connect(self._open_menu) + self.recent_action.toggled.connect(self._toggle_recent) + self.halt_action.triggered.connect(self._ask_halt) + self.clear_action.triggered.connect(self._ask_clear) + self.pause_button.clicked.connect(self._pause_or_resume) + self.abort_button.clicked.connect(lambda: self.ask_abort(self.abort_button)) + header_layout = QHBoxLayout(self.header) + header_layout.setContentsMargins(10, 10, 10, 8) + header_layout.setSpacing(8) + header_layout.addWidget(self.state_pill) + header_layout.addWidget(self.counts, 1) + header_layout.addWidget(self.pause_button) + header_layout.addWidget(self.abort_button) + header_layout.addWidget(self.more) + + # banner + self.banner = QFrame(self) + self.banner.setObjectName("banner") + self.banner_icon = QLabel(self.banner) + self.banner_title = QLabel(self.banner) + self.banner_title.setObjectName("title") + self.banner_text = QLabel(self.banner) + self.banner_text.setObjectName("muted") + self.banner_text.setWordWrap(True) + self.banner_resume = QPushButton("Resume", self.banner) + self.banner_resume.clicked.connect(controller.resume) + banner_text = QVBoxLayout() + banner_text.setSpacing(1) + banner_text.addWidget(self.banner_title) + banner_text.addWidget(self.banner_text) + banner_layout = QHBoxLayout(self.banner) + banner_layout.setContentsMargins(10, 8, 8, 8) + banner_layout.setSpacing(10) + banner_layout.addWidget(self.banner_icon, 0, Qt.AlignmentFlag.AlignTop) + banner_layout.addLayout(banner_text, 1) + banner_layout.addWidget(self.banner_resume) + banner_box = QWidget(self) + QVBoxLayout(banner_box).setContentsMargins(10, 0, 10, 8) + banner_box.layout().addWidget(self.banner) + self.banner_box = banner_box + + self.separator = QFrame(self) + self.separator.setObjectName("separator") + self.separator.setFixedHeight(1) + + # rows + self.scroll = QScrollArea(self) + self.scroll.setWidgetResizable(True) + self.scroll.setFrameShape(QFrame.Shape.NoFrame) + self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.list = QWidget() + self.list.setObjectName("list") + self.list_layout = QVBoxLayout(self.list) + self.list_layout.setContentsMargins(0, 0, 0, 0) + self.list_layout.setSpacing(0) + self.list_layout.addStretch(1) + self.scroll.setWidget(self.list) + + # empty state + self.empty = QWidget(self.scroll.viewport()) + self.empty_icon = QLabel(self.empty) + self.empty_title = QLabel("Queue is empty", self.empty) + self.empty_title.setObjectName("title") + self.empty_text = QLabel(self.empty) + self.empty_text.setObjectName("muted") + empty_layout = QVBoxLayout(self.empty) + for widget in (self.empty_icon, self.empty_title, self.empty_text): + empty_layout.addWidget(widget, 0, Qt.AlignmentFlag.AlignHCenter) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.header) + layout.addWidget(banner_box) + layout.addWidget(self.separator) + layout.addWidget(self.scroll, 1) + + controller.changed.connect(self._sync) + self.theme.changed.connect(self._apply_theme) + self._apply_theme() + + # ------------------------------------------------------------------ actions + + def ask_abort(self, anchor: QWidget): + """Confirm, then abort the running scan.""" + waiting = self.controller.waitingCount + stays = "" + if waiting: + stays = f"; the {waiting} waiting scan{'s stay' if waiting > 1 else ' stays'} queued" + self.popup.ask( + anchor, + f"Abort {self.controller.currentLabel}?", + "Data recorded so far is kept and the cleanup runs. " + f"The queue pauses afterwards{stays}.", + "Abort scan", + self.controller.abortCurrent, + ) + + def _ask_halt(self): + self.popup.ask( + self.more, + f"Halt {self.controller.currentLabel} without cleanup?", + "Stops immediately and skips the scan's cleanup routines, so motors may stay where " + "they are. Use Abort unless cleanup itself is the problem.", + "Halt scan", + self.controller.halt, + ) + + def _ask_clear(self): + c = self.controller + n = c.waitingCount + start = f"Stops {c.currentLabel} and removes " if c.runningCount else "Removes " + self.popup.ask( + self.more, + "Clear the queue?", + f"{start}{n} waiting scan{'' if n == 1 else 's'}. This cannot be undone, and the " + "queue is paused afterwards.", + "Clear queue", + c.clear, + ) + + def _open_menu(self): + self.recent_action.blockSignals(True) + self.recent_action.setChecked(self.controller.showRecent) + self.recent_action.blockSignals(False) + self.halt_action.setEnabled(self.controller.runningCount > 0) + self.clear_action.setEnabled( + self.controller.runningCount > 0 or self.controller.waitingCount > 0 + ) + self.menu.popup(self.more.mapToGlobal(QPoint(0, self.more.height() + 4))) + + def _toggle_recent(self, checked: bool): + self.controller.showRecent = checked + + def _pause_or_resume(self): + if self.controller.state == "paused": + self.controller.resume() + else: + self.controller.pause() + + # ------------------------------------------------------------------ rendering + + def _sync(self): + c = self.controller + state = c.state + label, icon, tone = { + "locked": ("Locked", "lock", "err"), + "paused": ("Paused", "pause", "warn"), + "running": ("Running", "play_arrow", "busy"), + }.get(state, ("Ready", "check", "neutral")) + self.state_pill.set_state(label, icon, tone) + counts = [f"{c.runningCount} running"] if c.runningCount else [] + counts.append(f"{c.waitingCount} waiting") + self.counts.setText(" · ".join(counts)) + paused, locked = state == "paused", state == "locked" + self.pause_button.setText("Resume" if paused or locked else "Pause") + self.pause_button.setProperty("iconName", "play_arrow" if paused or locked else "pause") + self.pause_button.setEnabled(not locked) + self.pause_button.setToolTip( + f"The queue is locked: {c.lockReason}" + if locked + else ( + "Let the next scan start" + if paused + else "Pause the queue: the running scan holds at its next checkpoint, " + "nothing new starts" + ) + ) + self.abort_button.setEnabled(c.runningCount > 0) + self.abort_button.setToolTip( + f"Abort {c.currentLabel} (asks first)" if c.runningCount else "No scan is running" + ) + self._apply_button_icons() + self.header.setVisible(c.toolbarVisible) + self.separator.setVisible(c.toolbarVisible) + + self.banner_box.setVisible(paused or locked) + self.banner.setProperty("tone", "err" if locked else "warn") + self.banner_title.setText("Queue locked" if locked else "Queue paused") + if locked: + reason = f"{c.lockReason}. " if c.lockReason else "" + self.banner_text.setText(reason + "Scans start again when the lock is released.") + elif c.runningCount: + self.banner_text.setText( + "The running scan holds at its next checkpoint; nothing new starts until " + "you resume." + ) + else: + self.banner_text.setText("Nothing new starts until you resume.") + self.banner_resume.setVisible(paused) + self.banner_icon.setPixmap( + material_icon( + "lock" if locked else "pause_circle", + size=(20, 20), + color=_hex(self.theme.errText if locked else self.theme.warnText), + filled=True, + ) + ) + self.banner.style().unpolish(self.banner) + self.banner.style().polish(self.banner) + + self._sync_rows() + + empty = not c.runningCount and not c.waitingCount and not c.recentCount + self.empty.setVisible(empty) + self.empty_text.setText( + f"Last: {c.lastFinished}" + if c.lastFinished + else "Scans you submit from Scan Control or the command line appear here." + ) + self._place_empty() + + def _sync_rows(self): + rows = self.controller.model.rows() + keys = {row.key for row in rows} + for key in list(self._rows): + if key not in keys: + widget = self._rows.pop(key) + self.list_layout.removeWidget(widget) + widget.deleteLater() + sections = [r.section for r in rows] + for name in list(self._section_labels): + if name not in sections: + label = self._section_labels.pop(name) + self.list_layout.removeWidget(label) + label.deleteLater() + index = 0 + previous = None + for row in rows: + if row.section != previous: + label = self._section_labels.get(row.section) + if label is None: + label = QLabel(self.list) + label.setObjectName("section") + self._section_labels[row.section] = label + title = "Recently finished" if row.section == "Recent" else row.section + suffix = f" · {self.controller.waitingCount}" if row.section == "Waiting" else "" + label.setText(title.upper() + suffix) + self.list_layout.insertWidget(index, label) + index += 1 + previous = row.section + widget = self._rows.get(row.key) + if widget is None: + widget = QueueRowWidget(self, row) + self._rows[row.key] = widget + else: + widget.set_row(row) + self.list_layout.insertWidget(index, widget) + index += 1 + + def _place_empty(self): + viewport = self.scroll.viewport() + self.empty.adjustSize() + self.empty.move( + (viewport.width() - self.empty.width()) // 2, + (viewport.height() - self.empty.height()) // 2, + ) + + def resizeEvent(self, event): # pylint: disable=invalid-name + """Keep the empty state centred.""" + super().resizeEvent(event) + self._place_empty() + + def event(self, event): + """Re-centre the empty state once the layout has settled.""" + if event.type() == QEvent.Type.LayoutRequest: + QTimer.singleShot(0, self._place_empty) + return super().event(event) + + def _apply_button_icons(self): + t = self.theme + pause_color = t.faint if not self.pause_button.isEnabled() else t.fg + self.pause_button.setIcon( + material_icon( + self.pause_button.property("iconName") or "pause", + color=_hex(pause_color), + filled=True, + convert_to_pixmap=False, + ) + ) + abort_color = t.errText if self.abort_button.isEnabled() else t.faint + self.abort_button.setIcon( + material_icon("stop", color=_hex(abort_color), filled=True, convert_to_pixmap=False) + ) + + def _apply_theme(self): + t = self.theme + h = { + name: _hex(getattr(t, name)) + for name in ( + "card", + "bg", + "fg", + "muted", + "faint", + "border", + "separator", + "hover", + "pressed", + "primary", + "err", + "errText", + "errTint", + "warnTint", + "busy", + "field", + ) + } + self.setStyleSheet(f""" + #queueView, #list, QScrollArea, QScrollArea > QWidget > QWidget {{ + background: {h['card']}; }} + QLabel {{ color: {h['fg']}; background: transparent; }} + QLabel#muted {{ color: {h['muted']}; font-size: 12px; }} + QLabel#small {{ color: {h['muted']}; font-size: 11px; }} + QLabel#title {{ font-size: 13px; font-weight: 600; }} + QLabel#confirmTitle {{ font-size: 14px; font-weight: 600; }} + QLabel#number {{ font-family: monospace; font-weight: 600; font-size: 12px; }} + QLabel#number[empty="true"] {{ color: {h['faint']}; }} + QLabel#sample {{ color: {h['muted']}; font-size: 11px; background: {h['field']}; + border: 1px solid {h['separator']}; border-radius: 4px; padding: 0 5px; }} + QLabel#section {{ color: {h['faint']}; background: {h['bg']}; font-size: 11px; + font-weight: 700; letter-spacing: 0.8px; padding: 6px 12px; }} + QFrame#separator {{ background: {h['separator']}; }} + QFrame#row {{ border: none; border-bottom: 1px solid {h['separator']}; + background: transparent; }} + QFrame#row:hover {{ background: {h['hover']}; }} + QFrame#banner {{ border-radius: 7px; }} + QFrame#banner[tone="warn"] {{ background: {h['warnTint']}; }} + QFrame#banner[tone="err"] {{ background: {h['errTint']}; }} + QFrame#confirm {{ background: {h['card']}; border: 1px solid {h['border']}; + border-radius: 9px; }} + QPushButton {{ color: {h['fg']}; background: transparent; font-size: 12px; + font-weight: 500; border: 1px solid {h['border']}; border-radius: 6px; + padding: 5px 12px; min-height: 16px; }} + QPushButton:hover {{ background: {h['hover']}; }} + QPushButton:pressed {{ background: {h['pressed']}; }} + QPushButton:focus {{ border: 2px solid {h['primary']}; }} + QPushButton:disabled {{ color: {h['faint']}; }} + QPushButton#danger {{ color: {h['errText']}; border-color: {h['err']}; }} + QPushButton#danger:disabled {{ color: {h['faint']}; border-color: {h['border']}; }} + QPushButton#dangerSolid, QPushButton#dangerSolidSmall {{ color: white; + background: {h['err']}; border: none; }} + QPushButton#small, QPushButton#dangerSolidSmall {{ padding: 3px 10px; }} + QToolButton#iconButton {{ border: none; border-radius: 6px; background: transparent; }} + QToolButton#iconButton:hover {{ background: {h['hover']}; }} + QToolButton#iconButton:focus {{ border: 2px solid {h['primary']}; }} + QProgressBar {{ background: {h['separator']}; border: none; border-radius: 2px; }} + QProgressBar::chunk {{ background: {h['busy']}; border-radius: 2px; }} + """) + _refresh_icon(self.more, t) + self.empty_icon.setPixmap( + material_icon("playlist_add", size=(30, 30), color=_hex(t.faint), filled=True) + ) + for widget in self._rows.values(): + widget.refresh_theme() + self._sync() + + +class BECQueueQt(BECQueue): + """ + The BEC queue with a QWidget view instead of QML, for comparing the two toolkits. + + Same shell, controller and behaviour as :class:`BECQueue`; only the rendering differs. + """ + + PLUGIN = False + RPC = False + + def _create_view(self) -> QWidget: + return QueueView(self.controller, self) + + def _release_view(self): + self.view.popup.close() + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication + + app = QApplication(sys.argv) + widget = BECQueueQt() + widget.show() + sys.exit(app.exec_()) diff --git a/tests/unit_tests/test_bec_queue_qt.py b/tests/unit_tests/test_bec_queue_qt.py new file mode 100644 index 00000000..b1424a60 --- /dev/null +++ b/tests/unit_tests/test_bec_queue_qt.py @@ -0,0 +1,54 @@ +import pytest + +from bec_widgets.tests.client_mocks import mocked_client +from bec_widgets.tests.utils import create_widget +from bec_widgets.widgets.services.bec_queue.bec_queue_qt import BECQueueQt + +from .test_bec_queue import _status_msg, busy_queue # pylint: disable=unused-import + + +@pytest.fixture +def qt_queue(qtbot, mocked_client): + widget = create_widget(qtbot, BECQueueQt, client=mocked_client, refresh_upon_start=False) + qtbot.waitExposed(widget) + yield widget + + +def test_qt_view_shows_rows_and_sections(qt_queue, busy_queue): + qt_queue.update_queue(busy_queue.content, {}) + view = qt_queue.view + assert len(view._rows) == 4 + assert set(view._section_labels) == {"Now", "Waiting"} + assert view.state_pill._text.text() == "Running" + assert view.counts.text() == "1 running · 3 waiting" + assert view.abort_button.isEnabled() + + +def test_qt_view_reuses_row_widgets(qt_queue, busy_queue): + qt_queue.update_queue(busy_queue.content, {}) + before = dict(qt_queue.view._rows) + qt_queue.update_queue(busy_queue.content, {}) + assert all(qt_queue.view._rows[k] is w for k, w in before.items()) + + +def test_qt_view_inline_remove(qt_queue, busy_queue): + qt_queue.update_queue(busy_queue.content, {}) + row = [w for w in qt_queue.view._rows.values() if w.row.section == "Waiting"][2] + row.remove.click() + assert row.actions.currentIndex() == 1 + qt_queue.client.queue.request_scan_abortion.assert_not_called() + row.confirm_remove.click() + qt_queue.client.queue.request_scan_abortion.assert_called_once_with( + request_id=row.row.request_id + ) + assert row.pill._text.text() == "Removing…" + + +def test_qt_view_paused_banner_and_empty_state(qt_queue, busy_queue): + info = busy_queue.queue["primary"].info + qt_queue.update_queue(_status_msg(info, status="PAUSED").content, {}) + assert not qt_queue.view.banner_box.isHidden() + assert qt_queue.view.pause_button.text() == "Resume" + qt_queue.update_queue({}, {}) + assert qt_queue.view._rows == {} + assert not qt_queue.view.empty.isHidden()