mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 00:01:27 +01:00
merge: bring in queue-widget-qml-ojrchl for the system dock, queue keeps its own QML host
This commit is contained in:
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
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user