mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-29 08:21:27 +01:00
feat(bec_queue): port queue widget to QML with state, progress, history and safer actions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2iSCsSSznEdY4HxoVaqoi
This commit is contained in:
11 files changed
+1810
-340
No files matched your search
@@ -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
|
||||
@@ -1,47 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from bec_lib import messages
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.logger import bec_logger
|
||||
from bec_qthemes import material_icon
|
||||
from qtpy.QtCore import Property, Qt, Signal, Slot
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Property, QUrl, Signal
|
||||
from qtpy.QtWidgets import QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
from bec_widgets.utils.compact_popup import CompactPopupWidget
|
||||
from bec_widgets.utils.toolbars.actions import WidgetAction
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.control.buttons.button_abort.button_abort import AbortButton
|
||||
from bec_widgets.widgets.control.buttons.button_reset.button_reset import ResetButton
|
||||
from bec_widgets.widgets.control.buttons.button_resume.button_resume import ResumeButton
|
||||
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.utils.qml_host import create_quick_widget
|
||||
from bec_widgets.widgets.services.bec_queue.queue_model import IDLE_STATUSES, QueueController
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
QML_FILE = Path(__file__).parent / "qml" / "BECQueue.qml"
|
||||
|
||||
|
||||
class BECQueue(BECWidget, CompactPopupWidget):
|
||||
"""
|
||||
Widget to display the BEC queue.
|
||||
|
||||
Shows the running scan with its progress, the waiting scans (reorder, remove) and the most
|
||||
recent finished scans, together with the queue state and Pause / Resume / Abort controls.
|
||||
The view is written in QML and hosted in a ``QQuickWidget``; this class is the QWidget shell
|
||||
that keeps the RPC, Designer and docking interface.
|
||||
"""
|
||||
|
||||
PLUGIN = True
|
||||
ICON_NAME = "edit_note"
|
||||
status_colors = {
|
||||
"STOPPED": "red",
|
||||
"PENDING": "orange",
|
||||
"IDLE": "gray",
|
||||
"PAUSED": "yellow",
|
||||
"DEFERRED_PAUSE": "lightyellow",
|
||||
"RUNNING": "green",
|
||||
"COMPLETED": "blue",
|
||||
}
|
||||
|
||||
queue_busy = Signal(bool)
|
||||
_history_ready = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -57,59 +51,32 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
)
|
||||
self.layout.setSpacing(0)
|
||||
self.layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.get_bec_shortcuts()
|
||||
self._toolbar_hidden = False
|
||||
self._known_queue_ids: set[str] = set()
|
||||
self._history_thread: threading.Thread | None = None
|
||||
|
||||
# Set up the toolbar
|
||||
self.set_toolbar()
|
||||
# Set up the table
|
||||
self.table = QTableWidget(self)
|
||||
# self.layout.addWidget(self.table)
|
||||
self.table.setColumnCount(5)
|
||||
self.table.setHorizontalHeaderLabels(
|
||||
["Scan Number", "Scan Name", "Type", "Status", "Cancel"]
|
||||
)
|
||||
self.controller = QueueController(self)
|
||||
self.controller.pause_requested.connect(self.pause_queue)
|
||||
self.controller.resume_requested.connect(self.resume_queue)
|
||||
self.controller.abort_requested.connect(self.abort_request)
|
||||
self.controller.halt_requested.connect(self.halt_scan)
|
||||
self.controller.remove_requested.connect(self.abort_request)
|
||||
self.controller.move_requested.connect(self.move_request)
|
||||
self.controller.clear_requested.connect(self.clear_queue)
|
||||
self._history_ready.connect(self.controller.set_history)
|
||||
|
||||
header = self.table.horizontalHeader()
|
||||
header.setSectionResizeMode(QHeaderView.Stretch)
|
||||
|
||||
self.addWidget(self.table)
|
||||
self.view = create_quick_widget(self, QML_FILE, {"queue": self.controller})
|
||||
self.view.setMinimumSize(320, 160)
|
||||
self.addWidget(self.view)
|
||||
self.label = "BEC Queue"
|
||||
self.tooltip = "BEC Queue status"
|
||||
|
||||
self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status())
|
||||
self.reset_content()
|
||||
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
|
||||
if refresh_upon_start:
|
||||
self.refresh_queue()
|
||||
|
||||
def set_toolbar(self):
|
||||
"""
|
||||
Set the toolbar.
|
||||
"""
|
||||
widget_label = QLabel(text="Live Queue", parent=self)
|
||||
widget_label.setStyleSheet("font-weight: bold;")
|
||||
self.toolbar = ModularToolBar(parent=self)
|
||||
self.toolbar.components.add_safe("widget_label", WidgetAction(widget=widget_label))
|
||||
bundle = ToolbarBundle("queue_label", self.toolbar.components)
|
||||
bundle.add_action("widget_label")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
|
||||
self.toolbar.add_action(
|
||||
"resume", WidgetAction(widget=ResumeButton(parent=self, toolbar=True))
|
||||
)
|
||||
self.toolbar.add_action("stop", WidgetAction(widget=StopButton(parent=self, toolbar=True)))
|
||||
self.toolbar.add_action(
|
||||
"reset", WidgetAction(widget=ResetButton(parent=self, toolbar=True))
|
||||
)
|
||||
|
||||
control_bundle = ToolbarBundle("control", self.toolbar.components)
|
||||
control_bundle.add_action("resume")
|
||||
control_bundle.add_action("stop")
|
||||
control_bundle.add_action("reset")
|
||||
self.toolbar.add_bundle(control_bundle)
|
||||
self.toolbar.show_bundles(["queue_label", "control"])
|
||||
|
||||
self.addWidget(self.toolbar)
|
||||
|
||||
@Property(bool)
|
||||
def hide_toolbar(self):
|
||||
"""Property to hide the BEC Queue toolbar."""
|
||||
@@ -133,194 +100,134 @@ class BECQueue(BECWidget, CompactPopupWidget):
|
||||
hide(bool): Whether to hide the toolbar.
|
||||
"""
|
||||
self._toolbar_hidden = hide
|
||||
self.toolbar.setVisible(not hide)
|
||||
self.controller.set_toolbar_visible(not hide)
|
||||
|
||||
def refresh_queue(self):
|
||||
"""
|
||||
Refresh the queue.
|
||||
Refresh the queue and the recent history.
|
||||
"""
|
||||
msg = self.client.connector.get(MessageEndpoints.scan_queue_status())
|
||||
self._fetch_history()
|
||||
if msg is None:
|
||||
# msg is None if no scan has been run yet (fresh start)
|
||||
return
|
||||
self.update_queue(msg.content, msg.metadata)
|
||||
|
||||
@Slot(dict, dict)
|
||||
@SafeSlot(dict, dict)
|
||||
def update_queue(self, content, _metadata):
|
||||
"""
|
||||
Update the queue table with the latest queue information.
|
||||
Update the view with the latest queue information.
|
||||
|
||||
Args:
|
||||
content (dict): The queue content.
|
||||
_metadata (dict): The metadata.
|
||||
"""
|
||||
# only show the primary queue for now
|
||||
queues = content.get("queue", {})
|
||||
if not queues:
|
||||
self.reset_content()
|
||||
return
|
||||
primary_queue: messages.ScanQueueStatus | None = queues.get("primary")
|
||||
if not primary_queue:
|
||||
self.reset_content()
|
||||
return
|
||||
queue_info = primary_queue.info
|
||||
primary_queue: messages.ScanQueueStatus | None = content.get("queue", {}).get("primary")
|
||||
self.controller.set_queue(primary_queue)
|
||||
queue_info = primary_queue.info if primary_queue else []
|
||||
|
||||
self.table.setRowCount(len(queue_info))
|
||||
self.table.clearContents()
|
||||
queue_ids = {item.queue_id for item in queue_info}
|
||||
if self._known_queue_ids - queue_ids:
|
||||
# something left the queue: it is now in the history
|
||||
self._fetch_history()
|
||||
self._known_queue_ids = queue_ids
|
||||
|
||||
if not queue_info:
|
||||
self.reset_content()
|
||||
return
|
||||
|
||||
for index, item in enumerate(queue_info):
|
||||
blocks = item.request_blocks
|
||||
scan_types = []
|
||||
scan_numbers = []
|
||||
scan_names = []
|
||||
user_metadatas = []
|
||||
request_ids = []
|
||||
status = item.status
|
||||
for request_block in blocks:
|
||||
scan_type = request_block.msg.scan_type
|
||||
user_metadata = request_block.msg.metadata.get("user_metadata", {})
|
||||
scan_name = user_metadata.get("scan_name", scan_type)
|
||||
if scan_type:
|
||||
scan_types.append(scan_type)
|
||||
scan_number = request_block.scan_number
|
||||
if scan_number:
|
||||
scan_numbers.append(str(scan_number))
|
||||
if scan_name:
|
||||
scan_names.append(scan_name)
|
||||
if user_metadata:
|
||||
user_metadatas.append(user_metadata)
|
||||
if request_block.RID:
|
||||
request_ids.append(request_block.RID)
|
||||
if scan_types:
|
||||
scan_types = ", ".join(scan_types)
|
||||
if scan_numbers:
|
||||
scan_numbers = ", ".join(scan_numbers)
|
||||
if scan_names:
|
||||
scan_names = ", ".join(scan_names)
|
||||
# Pretty print user metadata as tooltip
|
||||
tooltip = ""
|
||||
if user_metadatas:
|
||||
if len(user_metadatas) == 1:
|
||||
tooltip = json.dumps(user_metadatas[0], indent=2)
|
||||
else:
|
||||
tooltip = json.dumps(user_metadatas, indent=2)
|
||||
self.set_row(
|
||||
index,
|
||||
scan_numbers,
|
||||
scan_names,
|
||||
scan_types,
|
||||
status,
|
||||
tooltip,
|
||||
abort_request_id=request_ids[0] if request_ids else "",
|
||||
)
|
||||
busy = (
|
||||
False
|
||||
if all(item.status in ("STOPPED", "COMPLETED", "IDLE") for item in queue_info)
|
||||
else True
|
||||
)
|
||||
busy = not all(item.status in IDLE_STATUSES for item in queue_info)
|
||||
self.set_global_state("warning" if busy else "default")
|
||||
self.queue_busy.emit(busy)
|
||||
|
||||
def format_item(self, content: str, status=False, tooltip: str = "") -> QTableWidgetItem:
|
||||
@SafeSlot(dict, dict)
|
||||
def on_scan_progress(self, content: dict, metadata: dict):
|
||||
"""
|
||||
Format the content of the table item.
|
||||
Show the progress of the running scan.
|
||||
|
||||
Args:
|
||||
content (str): The content to be formatted.
|
||||
tooltip (str): Optional tooltip to display.
|
||||
Returns:
|
||||
QTableWidgetItem: The formatted item.
|
||||
content (dict): ProgressMessage content.
|
||||
metadata (dict): ProgressMessage metadata.
|
||||
"""
|
||||
if not content or not isinstance(content, str):
|
||||
content = ""
|
||||
item = QTableWidgetItem(content)
|
||||
item.setTextAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
|
||||
if tooltip:
|
||||
item.setToolTip(tooltip)
|
||||
|
||||
if status:
|
||||
try:
|
||||
color = self.status_colors.get(content, "black") # Default to black if not found
|
||||
item.setForeground(QColor(color))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not apply status color for queue item '{content}': {e}")
|
||||
return item
|
||||
|
||||
def set_row(
|
||||
self,
|
||||
index: int,
|
||||
scan_number: str,
|
||||
scan_name: str,
|
||||
scan_type: str,
|
||||
status: str,
|
||||
tooltip: str = "",
|
||||
abort_request_id: str = "",
|
||||
):
|
||||
"""
|
||||
Set the row of the table.
|
||||
|
||||
Args:
|
||||
index (int): The index of the row.
|
||||
scan_number (str): The scan number.
|
||||
scan_name (str): The scan name.
|
||||
scan_type (str): The scan type.
|
||||
status (str): The status.
|
||||
tooltip (str): Optional tooltip to display (pretty-printed user metadata).
|
||||
abort_request_id (str): request id to abort.
|
||||
"""
|
||||
abort_button = self._create_abort_button(abort_request_id)
|
||||
abort_button.button.clicked.connect(self.delete_selected_row)
|
||||
|
||||
self.table.setItem(index, 0, self.format_item(scan_number, tooltip=tooltip))
|
||||
self.table.setItem(index, 1, self.format_item(scan_name, tooltip=tooltip))
|
||||
self.table.setItem(index, 2, self.format_item(scan_type, tooltip=tooltip))
|
||||
self.table.setItem(index, 3, self.format_item(status, status=True, tooltip=tooltip))
|
||||
self.table.setCellWidget(index, 4, abort_button)
|
||||
|
||||
def _create_abort_button(self, request_id: str) -> AbortButton:
|
||||
"""
|
||||
Create an abort button with styling for BEC Queue widget for certain request_id.
|
||||
|
||||
Args:
|
||||
request_id(str): The request id to abort.
|
||||
|
||||
Returns:
|
||||
AbortButton: The abort button.
|
||||
"""
|
||||
abort_button = AbortButton(parent=self, request_id=request_id)
|
||||
|
||||
abort_button.button.setText("")
|
||||
abort_button.button.setIcon(
|
||||
material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False)
|
||||
self.controller.set_progress(
|
||||
metadata.get("scan_id"),
|
||||
content.get("value", 0),
|
||||
content.get("max_value", 0),
|
||||
content.get("done", False),
|
||||
)
|
||||
abort_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
}
|
||||
""")
|
||||
|
||||
return abort_button
|
||||
def _fetch_history(self):
|
||||
"""Read the queue history from Redis off the GUI thread."""
|
||||
if self._history_thread is not None and self._history_thread.is_alive():
|
||||
return
|
||||
|
||||
def delete_selected_row(self):
|
||||
def worker():
|
||||
try:
|
||||
history = self.client.connector.lrange(MessageEndpoints.scan_queue_history(), 0, 9)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
logger.warning(f"Could not read the queue history: {exc}")
|
||||
return
|
||||
if isinstance(history, list):
|
||||
self._history_ready.emit(
|
||||
[m for m in history if isinstance(m, messages.ScanQueueHistoryMessage)]
|
||||
)
|
||||
|
||||
button = self.sender()
|
||||
row = self.table.indexAt(button.pos()).row()
|
||||
self.table.removeRow(row)
|
||||
button.close()
|
||||
button.deleteLater()
|
||||
self._history_thread = threading.Thread(target=worker, daemon=True)
|
||||
self._history_thread.start()
|
||||
|
||||
def reset_content(self):
|
||||
"""
|
||||
Reset the content of the table.
|
||||
################################################################################
|
||||
# Requests to BEC
|
||||
################################################################################
|
||||
|
||||
@SafeSlot()
|
||||
def pause_queue(self):
|
||||
"""Pause the queue: the running scan pauses at its next checkpoint, nothing new starts."""
|
||||
self.client.connector.send(
|
||||
MessageEndpoints.scan_queue_modification_request(),
|
||||
messages.ScanQueueModificationMessage(
|
||||
scan_id=None, action="deferred_pause", parameter={}
|
||||
),
|
||||
)
|
||||
|
||||
@SafeSlot()
|
||||
def resume_queue(self):
|
||||
"""Resume the queue."""
|
||||
self.queue.request_scan_continuation()
|
||||
|
||||
@SafeSlot(str)
|
||||
def abort_request(self, request_id: str):
|
||||
"""
|
||||
Abort a request: the running scan is aborted, a waiting one is removed from the queue.
|
||||
|
||||
self.table.setRowCount(1)
|
||||
self.set_row(0, "", "", "", "", "")
|
||||
Args:
|
||||
request_id(str): The request to abort; empty aborts the current request.
|
||||
"""
|
||||
self.queue.request_scan_abortion(request_id=request_id or None)
|
||||
|
||||
@SafeSlot()
|
||||
def halt_scan(self):
|
||||
"""Halt the running scan without its cleanup routines."""
|
||||
self.queue.request_scan_halt()
|
||||
|
||||
@SafeSlot(str, str)
|
||||
def move_request(self, scan_id: str, action: str):
|
||||
"""
|
||||
Move a waiting scan within the queue.
|
||||
|
||||
Args:
|
||||
scan_id(str): Scan id of the queue item.
|
||||
action(str): "move_up", "move_down", "move_top" or "move_bottom".
|
||||
"""
|
||||
self.queue.request_queue_order_modification(scan_id=scan_id, action=action)
|
||||
|
||||
@SafeSlot()
|
||||
def clear_queue(self):
|
||||
"""Stop the running scan and remove every waiting scan."""
|
||||
self.queue.request_queue_reset()
|
||||
|
||||
def cleanup(self):
|
||||
"""Release the QML view before the controller it binds to."""
|
||||
if self._history_thread is not None:
|
||||
self._history_thread.join(timeout=1)
|
||||
self.view.setSource(QUrl())
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -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,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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user