From 0d7c10e670e4937787e1afaa19ca8259ac752486 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 29 Aug 2024 17:01:47 +0200 Subject: [PATCH] feat(queue): BECQueue controls extended with Resume, Stop, Abort, Reset buttons --- bec_widgets/qt_utils/toolbar.py | 1 + bec_widgets/widgets/bec_queue/bec_queue.py | 146 ++++++++++++++++-- .../widgets/button_abort/button_abort.py | 16 +- .../widgets/button_reset/button_reset.py | 8 +- .../widgets/button_resume/button_resume.py | 6 +- .../widgets/stop_button/stop_button.py | 6 +- docs/user/widgets/queue/queue.md | 3 +- tests/unit_tests/test_bec_dock.py | 8 +- tests/unit_tests/test_bec_queue.py | 35 ++++- 9 files changed, 201 insertions(+), 28 deletions(-) diff --git a/bec_widgets/qt_utils/toolbar.py b/bec_widgets/qt_utils/toolbar.py index 91d25d19..5e2db8b8 100644 --- a/bec_widgets/qt_utils/toolbar.py +++ b/bec_widgets/qt_utils/toolbar.py @@ -162,6 +162,7 @@ class WidgetAction(ToolBarAction): def add_to_toolbar(self, toolbar, target): widget = QWidget() layout = QHBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) if self.label is not None: label = QLabel(f"{self.label}") layout.addWidget(label) diff --git a/bec_widgets/widgets/bec_queue/bec_queue.py b/bec_widgets/widgets/bec_queue/bec_queue.py index 8fc7a6ad..a7d7369c 100644 --- a/bec_widgets/widgets/bec_queue/bec_queue.py +++ b/bec_widgets/widgets/bec_queue/bec_queue.py @@ -1,11 +1,18 @@ from __future__ import annotations from bec_lib.endpoints import MessageEndpoints -from qtpy.QtCore import Qt, Slot -from qtpy.QtWidgets import QHBoxLayout, QHeaderView, QTableWidget, QTableWidgetItem, QWidget +from qtpy.QtCore import Property, Qt, Slot +from qtpy.QtGui import QColor +from qtpy.QtWidgets import QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget +from bec_qthemes import material_icon +from bec_widgets.qt_utils.toolbar import ModularToolBar, SeparatorAction, WidgetAction from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.widgets.button_abort.button_abort import AbortButton +from bec_widgets.widgets.button_reset.button_reset import ResetButton +from bec_widgets.widgets.button_resume.button_resume import ResumeButton +from bec_widgets.widgets.stop_button.stop_button import StopButton class BECQueue(BECWidget, QWidget): @@ -14,6 +21,15 @@ class BECQueue(BECWidget, QWidget): """ ICON_NAME = "edit_note" + status_colors = { + "STOPPED": "red", + "PENDING": "orange", + "IDLE": "gray", + "PAUSED": "yellow", + "DEFERRED_PAUSE": "lightyellow", + "RUNNING": "green", + "COMPLETED": "blue", + } def __init__( self, @@ -21,19 +37,79 @@ class BECQueue(BECWidget, QWidget): client=None, config: ConnectionConfig = None, gui_id: str = None, + refresh_upon_start: bool = True, ): super().__init__(client, config, gui_id) QWidget.__init__(self, parent=parent) - self.table = QTableWidget(self) - self.layout = QHBoxLayout(self) - self.layout.addWidget(self.table) + self.layout = QVBoxLayout(self) + self.layout.setSpacing(0) + self.layout.setContentsMargins(0, 0, 0, 0) - self.table.setColumnCount(3) - self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status"]) + # Set up the toolbar + self.set_toolbar() + + # Set up the table + self.table = QTableWidget(self) + self.layout.addWidget(self.table) + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels(["Scan Number", "Type", "Status", "Cancel"]) header = self.table.horizontalHeader() header.setSectionResizeMode(QHeaderView.Stretch) + self.bec_dispatcher.connect_slot(self.update_queue, MessageEndpoints.scan_queue_status()) self.reset_content() + if refresh_upon_start: + self.refresh_queue() + + def set_toolbar(self): + """ + Set the toolbar. + """ + widget_label = QLabel("Live Queue") + widget_label.setStyleSheet("font-weight: bold;") + self.toolbar = ModularToolBar( + actions={ + "widget_label": WidgetAction(widget=widget_label), + "separator_1": SeparatorAction(), + "resume": WidgetAction(widget=ResumeButton(toolbar=False)), + "stop": WidgetAction(widget=StopButton(toolbar=False)), + "reset": WidgetAction(widget=ResetButton(toolbar=False)), + }, + target_widget=self, + ) + + self.layout.addWidget(self.toolbar) + + @Property(bool) + def hide_toolbar(self): + """Property to hide the BEC Queue toolbar.""" + return not self.toolbar.isVisible() + + @hide_toolbar.setter + def hide_toolbar(self, hide: bool): + """ + Setters for the hide_toolbar property. + + Args: + hide(bool): Whether to hide the toolbar. + """ + self._hide_toolbar(hide) + + def _hide_toolbar(self, hide: bool): + """ + Hide the toolbar. + + Args: + hide(bool): Whether to hide the toolbar. + """ + self.toolbar.setVisible(not hide) + + def refresh_queue(self): + """ + Refresh the queue. + """ + msg = self.client.connector.get(MessageEndpoints.scan_queue_status()) + self.update_queue(msg.content, msg.metadata) @Slot(dict, dict) def update_queue(self, content, _metadata): @@ -57,6 +133,7 @@ class BECQueue(BECWidget, QWidget): blocks = item.get("request_blocks", []) scan_types = [] scan_numbers = [] + scan_ids = [] status = item.get("status", "") for request_block in blocks: scan_type = request_block.get("content", {}).get("scan_type", "") @@ -65,13 +142,18 @@ class BECQueue(BECWidget, QWidget): scan_number = request_block.get("scan_number", "") if scan_number: scan_numbers.append(str(scan_number)) + scan_id = request_block.get("scan_id", "") + if scan_id: + scan_ids.append(scan_id) if scan_types: scan_types = ", ".join(scan_types) if scan_numbers: scan_numbers = ", ".join(scan_numbers) - self.set_row(index, scan_numbers, scan_types, status) + if scan_ids: + scan_ids = ", ".join(scan_ids) + self.set_row(index, scan_numbers, scan_types, status, scan_ids) - def format_item(self, content: str) -> QTableWidgetItem: + def format_item(self, content: str, status=False) -> QTableWidgetItem: """ Format the content of the table item. @@ -85,9 +167,17 @@ class BECQueue(BECWidget, QWidget): content = "" item = QTableWidgetItem(content) item.setTextAlignment(Qt.AlignHCenter | Qt.AlignVCenter) + # item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + + if status: + try: + color = self.status_colors.get(content, "black") # Default to black if not found + item.setForeground(QColor(color)) + except: + return item return item - def set_row(self, index: int, scan_number: str, scan_type: str, status: str): + def set_row(self, index: int, scan_number: str, scan_type: str, status: str, scan_id: str): """ Set the row of the table. @@ -97,10 +187,42 @@ class BECQueue(BECWidget, QWidget): scan_type (str): The scan type. status (str): The status. """ + abort_button = self._create_abort_button(scan_id) + abort_button.button.clicked.connect(self.delete_selected_row) self.table.setItem(index, 0, self.format_item(scan_number)) self.table.setItem(index, 1, self.format_item(scan_type)) - self.table.setItem(index, 2, self.format_item(status)) + self.table.setItem(index, 2, self.format_item(status, status=True)) + self.table.setCellWidget(index, 3, abort_button) + + def _create_abort_button(self, scan_id: str) -> AbortButton: + """ + Create an abort button with styling for BEC Queue widget for certain scan_id. + + Args: + scan_id(str): The scan id to abort. + + Returns: + AbortButton: The abort button. + """ + abort_button = AbortButton(scan_id=scan_id) + + abort_button.button.setText("") + abort_button.button.setIcon( + material_icon("cancel", color="#cc181e", filled=True, convert_to_pixmap=False) + ) + abort_button.button.setStyleSheet("background-color: rgba(0,0,0,0) ") + abort_button.button.setFlat(True) + + return abort_button + + def delete_selected_row(self): + + button = self.sender() + row = self.table.indexAt(button.pos()).row() + self.table.removeRow(row) + + button.deleteLater() def reset_content(self): """ @@ -108,7 +230,7 @@ class BECQueue(BECWidget, QWidget): """ self.table.setRowCount(1) - self.set_row(0, "", "", "") + self.set_row(0, "", "", "", "") if __name__ == "__main__": # pragma: no cover diff --git a/bec_widgets/widgets/button_abort/button_abort.py b/bec_widgets/widgets/button_abort/button_abort.py index 961d1e8d..531d22b6 100644 --- a/bec_widgets/widgets/button_abort/button_abort.py +++ b/bec_widgets/widgets/button_abort/button_abort.py @@ -11,7 +11,9 @@ class AbortButton(BECWidget, QWidget): ICON_NAME = "cancel" - def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False): + def __init__( + self, parent=None, client=None, config=None, gui_id=None, toolbar=False, scan_id=None + ): super().__init__(client=client, config=config, gui_id=gui_id) QWidget.__init__(self, parent=parent) @@ -25,17 +27,19 @@ class AbortButton(BECWidget, QWidget): if toolbar: icon = material_icon("cancel", color="#666666", filled=True) self.button = QToolButton(icon=icon) - self.button.triggered.connect(self.abort_scan) + self.button.setToolTip("Abort the scan") else: self.button = QPushButton() self.button.setText("Abort") self.button.setStyleSheet( "background-color: #666666; color: white; font-weight: bold; font-size: 12px;" ) - self.button.clicked.connect(self.abort_scan) + self.button.clicked.connect(self.abort_scan) self.layout.addWidget(self.button) + self.scan_id = scan_id + @SafeSlot() def abort_scan( self, @@ -46,4 +50,8 @@ class AbortButton(BECWidget, QWidget): Args: scan_id(str|None): The scan id to abort. If None, the current scan will be aborted. """ - self.queue.request_scan_abortion() + if self.scan_id is not None: + print(f"Aborting scan with scan_id: {self.scan_id}") + self.queue.request_scan_abortion(scan_id=self.scan_id) + else: + self.queue.request_scan_abortion() diff --git a/bec_widgets/widgets/button_reset/button_reset.py b/bec_widgets/widgets/button_reset/button_reset.py index da50832d..977ae2e6 100644 --- a/bec_widgets/widgets/button_reset/button_reset.py +++ b/bec_widgets/widgets/button_reset/button_reset.py @@ -23,16 +23,18 @@ class ResetButton(BECWidget, QWidget): self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) if toolbar: - icon = material_icon("restart_alt", color="#F19E39", filled=True) + icon = material_icon( + "restart_alt", color="#F19E39", filled=True, convert_to_pixmap=False + ) self.button = QToolButton(icon=icon) - self.button.triggered.connect(self.reset_queue) + self.button.setToolTip("Reset the scan queue") else: self.button = QPushButton() self.button.setText("Reset Queue") self.button.setStyleSheet( "background-color: #F19E39; color: white; font-weight: bold; font-size: 12px;" ) - self.button.clicked.connect(self.reset_queue) + self.button.clicked.connect(self.reset_queue) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/button_resume/button_resume.py b/bec_widgets/widgets/button_resume/button_resume.py index 6f28e1d5..ee5fd716 100644 --- a/bec_widgets/widgets/button_resume/button_resume.py +++ b/bec_widgets/widgets/button_resume/button_resume.py @@ -23,16 +23,16 @@ class ResumeButton(BECWidget, QWidget): self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) if toolbar: - icon = material_icon("resume", color="#2793e8", filled=True) + icon = material_icon("resume", color="#2793e8", filled=True, convert_to_pixmap=False) self.button = QToolButton(icon=icon) - self.button.triggered.connect(self.continue_scan) + self.button.setToolTip("Resume the scan queue") else: self.button = QPushButton() self.button.setText("Resume") self.button.setStyleSheet( "background-color: #2793e8; color: white; font-weight: bold; font-size: 12px;" ) - self.button.clicked.connect(self.continue_scan) + self.button.clicked.connect(self.continue_scan) self.layout.addWidget(self.button) diff --git a/bec_widgets/widgets/stop_button/stop_button.py b/bec_widgets/widgets/stop_button/stop_button.py index 41e7c504..62769b64 100644 --- a/bec_widgets/widgets/stop_button/stop_button.py +++ b/bec_widgets/widgets/stop_button/stop_button.py @@ -23,16 +23,16 @@ class StopButton(BECWidget, QWidget): self.layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) if toolbar: - icon = material_icon("stop", color="#cc181e", filled=True) + icon = material_icon("stop", color="#cc181e", filled=True, convert_to_pixmap=False) self.button = QToolButton(icon=icon) - self.button.triggered.connect(self.stop_scan) + self.button.setToolTip("Stop the scan queue") else: self.button = QPushButton() self.button.setText("Stop") self.button.setStyleSheet( "background-color: #cc181e; color: white; font-weight: bold; font-size: 12px;" ) - self.button.clicked.connect(self.stop_scan) + self.button.clicked.connect(self.stop_scan) self.layout.addWidget(self.button) diff --git a/docs/user/widgets/queue/queue.md b/docs/user/widgets/queue/queue.md index ef621828..a2eceb38 100644 --- a/docs/user/widgets/queue/queue.md +++ b/docs/user/widgets/queue/queue.md @@ -4,11 +4,12 @@ ````{tab} Overview -The [`BEC Queue Widget`](/api_reference/_autosummary/bec_widgets.cli.client.BECQueue) provides a real-time display of the BEC scan queue, allowing users to monitor and track the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. This widget is particularly useful for users who need to manage and oversee multiple scans in the BEC environment. +The [`BEC Queue Widget`](/api_reference/_autosummary/bec_widgets.cli.client.BECQueue) provides a real-time display and control of the BEC scan queue, allowing users to monitor, manage, and control the status of ongoing and pending scans. The widget automatically updates to reflect the current state of the scan queue, displaying critical information such as scan numbers, types, and statuses. Additionally, it provides control options to stop individual scans, stop the entire queue, resume, and reset the queue, making it a powerful tool for managing scan operations in the BEC environment. ## Key Features: - **Real-Time Queue Monitoring**: Displays the current state of the BEC scan queue, with automatic updates as the queue changes. - **Detailed Scan Information**: Provides a clear view of scan numbers, types, and statuses, helping users track the progress and state of each scan. +- **Queue Control**: Allows users to stop specific scans, stop the entire queue, resume paused scans, and reset the queue. - **Interactive Table Layout**: The queue is presented in a table format, with customizable columns that stretch to fit the available space. - **Flexible Integration**: The widget can be integrated into both [`BECDockArea`](user.widgets.bec_dock_area) and used as an individual component in your application through `QtDesigner`. diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py index 99df7d12..3fbd02af 100644 --- a/tests/unit_tests/test_bec_dock.py +++ b/tests/unit_tests/test_bec_dock.py @@ -2,10 +2,13 @@ from unittest.mock import MagicMock, patch import pytest +from bec_lib.endpoints import MessageEndpoints +from bec_lib.messages import ScanQueueStatusMessage from bec_widgets.widgets.dock import BECDock, BECDockArea from .client_mocks import mocked_client +from .test_bec_queue import bec_queue_msg_full @pytest.fixture @@ -135,7 +138,10 @@ def test_toolbar_add_device_positioner_box(bec_dock_area): ) -def test_toolbar_add_utils_queue(bec_dock_area): +def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full): + bec_dock_area.client.connector.set_and_publish( + MessageEndpoints.scan_queue_status(), bec_queue_msg_full + ) bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger() assert "queue_1" in bec_dock_area.panels assert bec_dock_area.panels["queue_1"].widgets[0].config.widget_class == "BECQueue" diff --git a/tests/unit_tests/test_bec_queue.py b/tests/unit_tests/test_bec_queue.py index f5f24268..b1fa9723 100644 --- a/tests/unit_tests/test_bec_queue.py +++ b/tests/unit_tests/test_bec_queue.py @@ -89,7 +89,7 @@ def bec_queue_msg_full(): @pytest.fixture def bec_queue(qtbot, mocked_client): - widget = BECQueue(client=mocked_client) + widget = BECQueue(client=mocked_client, refresh_upon_start=False) qtbot.addWidget(widget) qtbot.waitExposed(widget) yield widget @@ -109,3 +109,36 @@ def test_bec_queue_empty(bec_queue): assert bec_queue.table.item(0, 0).text() == "" assert bec_queue.table.item(0, 1).text() == "" assert bec_queue.table.item(0, 2).text() == "" + + +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 + + 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() == "COMPLETED" + + abort_button = bec_queue.table.cellWidget(0, 3) + abort_button.button.click() + + 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() == "COMPLETED" + + abort_button = bec_queue.table.cellWidget(0, 3) + abort_button.button.click() + + 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() == "COMPLETED" + + abort_button = bec_queue.table.cellWidget(0, 3) + abort_button.button.click()