merge: bring in queue-widget-qml-ojrchl for the system dock, queue keeps its own QML host

This commit is contained in:
Claude committed 2026-10-09 21:34:43 +00:00
commit 4acf8dd833
13 files changed
+2623 -338

No files matched your search

@@ -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.widgets.services.bec_queue.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,58 +51,39 @@ 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 = self._create_view()
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)
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})
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)
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):
@@ -133,194 +108,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):
"""Wait for a pending history read and release the view."""
if self._history_thread is not None:
self._history_thread.join(timeout=1)
self._release_view()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
@@ -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.widgets.services.bec_queue.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_())
@@ -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
}
}
}
@@ -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)
: ""
}
}
@@ -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
}
}
}
@@ -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) }
}
}
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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/<name>`` — 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/<icon_name>?color=<hex without #>&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
@@ -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()
+232 -115
View File
@@ -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"]
+54
View File
@@ -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()
@@ -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