1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-10 18:50:55 +02:00

Compare commits

...

74 Commits

Author SHA1 Message Date
David Perl
bcc246cf92 WIP drag and drop form widget 2025-04-17 16:33:40 +02:00
David Perl
550753078b WIP look at form widgets 2025-04-17 10:49:57 +02:00
ef148317de fix: wrap fetching plugin widgets in case of errors 2025-04-15 20:13:11 +02:00
e10f5ec088 test(launch_window): tests for default and plugin auto updates 2025-04-15 12:26:09 +02:00
33a8a767f3 test(launch_window): add test for launching UI file that raises ValueError for QMainWindow 2025-04-15 12:08:06 +02:00
8efa93d2d2 feat(launch_window): add user access permissions 2025-04-15 12:07:54 +02:00
29653239c5 feat(launch_window): enhance auto update functionality with selector and dynamic loading 2025-04-15 11:44:26 +02:00
778230b5ed feat(auto_updates): enforce rpc widget class for subclasses of auto updates 2025-04-15 11:41:03 +02:00
b7795b4d0a refactor(client_utils): remove unused auto update attributes from BECGuiClient 2025-04-15 11:40:22 +02:00
c434af9b92 feat(plugin_utils): add functionality to retrieve auto update classes from plugins 2025-04-15 11:40:04 +02:00
be722683a7 fix(main_window): show app id only when connected to redis 2025-04-15 09:10:35 +02:00
9a940bb8d5 refactor(launch_window): remove cleanup method 2025-04-15 08:59:17 +02:00
a6ce312f7c refactor(ui_loader): remove unused import 2025-04-15 08:58:59 +02:00
d5e422c7fc test(launch_window): add unit tests for LaunchWindow initialization and custom UI file launching 2025-04-15 08:58:14 +02:00
3cd6e05b24 fix(launch_window): update LaunchTile icon to use new UI loader tile image 2025-04-14 21:56:27 +02:00
3089ca15ec feat(launch_window): add custom UI file launching functionality and UI tile 2025-04-14 21:42:22 +02:00
d60cf6c843 refactor(ui_loader): remove unnecessary parent_id handling 2025-04-14 21:41:54 +02:00
45cd82e635 feat(ui_launch_window): add UILaunchWindow class 2025-04-14 21:40:46 +02:00
f653fc5f7e feat(positioner_box): add units QLabel to device UI components and update visibility logic 2025-04-14 13:33:11 +02:00
d6fccd10f5 fix(rpc_server): update _serialize_bec_connector to include wait parameter for registration check 2025-04-14 10:26:31 +02:00
064343acf2 fix(bec_connector): add setObjectName method to update object name and broadcast if registered; closes #472 2025-04-14 10:26:31 +02:00
82b82659b7 fix(rpc_register): change add_rpc parameter type to BECConnector and add object_is_registered method 2025-04-14 10:26:31 +02:00
1921444e15 fix(bec_connector): add assertion to ensure BECConnector is used with a QObject; closes #475 2025-04-14 10:26:31 +02:00
3b16c9f5a2 fix(bec_connector): move RPC registration into single shot method to ensure the rpc name is in sync 2025-04-14 10:26:31 +02:00
4381fcc4c2 fix(designer): avoid touching deleted widgets during init as QtDesigner will segfault 2025-04-14 10:26:31 +02:00
e4e9febc98 fix(ring_progress_bar): replaced hard-coded endpoints by MessageEndpoints 2025-04-14 10:16:47 +02:00
ac9224e5f2 refactor(auto_updates): move cleanup method from user section to internal section 2025-04-14 10:04:43 +02:00
18e4ba6cfe fix(auto_updates): fix condition to skip auto update 2025-04-14 10:04:43 +02:00
cfc8272ac2 docs: add missing class doc strings for rpc-enabled widgets 2025-04-12 21:14:01 +02:00
d2c90757c2 docs: better document logpanel code 2025-04-11 18:27:28 +02:00
1d7b423bb3 fix: warning in logpanel
- chain a signal to the child BecLogsQueue rather than passing the
signal instance in
2025-04-11 18:27:28 +02:00
cb91ebc0c3 refactor(rpc_server): add type hint for _get_becwidget_ancestor method parameter; minor cleanup of imports 2025-04-11 13:39:26 +02:00
08168f28d3 refactor(rpc_server): add type hints and docstrings for heartbeat and registry update methods 2025-04-11 13:37:42 +02:00
125afc8907 fix(rpc_server): enhance serialization logic for BECConnector objects and fix return types 2025-04-11 13:34:05 +02:00
4dc59aa5e9 fix(rpc_base): ensure message wait event is set after processing RPC response 2025-04-11 13:28:28 +02:00
96b31a4509 fix(client_utils): simplify RPC client instantiation in BECGuiClient 2025-04-11 13:25:10 +02:00
20a86ad325 fix(server): turn_off_the_lights cleanup fixed for parent_id widgets 2025-04-11 10:54:45 +02:00
7e65d4f2d6 fix(launch_window): redesign 2025-04-11 10:54:45 +02:00
11feeff37c fix(main_window): connected to theme change 2025-04-11 10:45:28 +02:00
c1bbb16dad fix(round_frame): orientation can be vertical 2025-04-11 10:45:28 +02:00
a5f1f4781e build(bec_lib): raised required version to 3.28.1 2025-04-11 10:45:28 +02:00
56c2827140 refactor(auto_update): auto_update changed to be BECMainWindow; removed auto update logic from BECDockArea 2025-04-11 10:45:28 +02:00
b03d2eaeed fix(waveform): dap curve flickering 2025-04-11 10:45:28 +02:00
3a82c95f60 fix(waveform, rpc_reference): __getitem__ removed form waveform and rpc_reference 2025-04-11 10:45:28 +02:00
5f272a66a4 feat(auto_update): add GUI highlight management for auto updates status 2025-04-11 10:45:28 +02:00
55baa84eb6 feat(main_window): add launcher menu and functionality to show launcher 2025-04-11 10:45:28 +02:00
b51d637c5f test(plot_base): test for plot base re-enabled 2025-04-11 10:45:28 +02:00
c97db6aaae fix(client): regenerated client 2025-04-11 10:45:28 +02:00
e725de3c45 fix(dock_area): close BECMainWindow if dock area is central widget 2025-04-11 10:45:28 +02:00
6082e7a690 refactor(rpc_server): cli_server renamed to rpc_server 2025-04-11 10:45:28 +02:00
8914f1d506 test(setting_dialog): test that settings reject calls cleanup 2025-04-11 10:45:28 +02:00
d06605122e test: qapp must shutdown cli server before checking for leaked QTimer 2025-04-11 10:45:28 +02:00
a8adb064f5 test(generate_cli): fix reference output 2025-04-11 10:45:28 +02:00
31c3b64d7b test(device_signal_input): fix init of device input widget 2025-04-11 10:45:28 +02:00
23bdd95d8c test(bec_connector): BECConnector requires a QObject 2025-04-11 10:45:28 +02:00
d1712552ff fix(cli): add type ignore comment to generated files 2025-04-11 10:45:28 +02:00
20a1c5ddb3 feat(launcher): add option for launching with auto updates 2025-04-11 10:45:28 +02:00
2511056557 feat!: add support for auto updates 2025-04-11 10:45:27 +02:00
99383b7715 refactor(launcher,main_window): launcher window moved to inherit from BECMainWindow 2025-04-11 10:45:27 +02:00
337a332ed1 fix(plot_framework): all widgets, popups and side menus cleanups adjusted 2025-04-11 10:45:27 +02:00
a1bec75115 fix(widgets)!: BECConnector resolves hierarchy including objectName, parent, parent_id upon init; all widgets adjusted 2025-04-11 10:45:27 +02:00
a2128ad8d6 fix(RPCReference): setattr added 2025-04-10 16:11:59 +02:00
5f27a90989 feat(server,launcher)!: RPC server separated with the launcher window introduced 2025-04-10 16:11:59 +02:00
39164feb18 fix(waveform): signals for x device can be defined from gui 2025-04-09 23:52:31 +02:00
af28e2e433 fix: support auto_range_x/y for viewAll during measurement 2025-04-09 14:35:52 +02:00
515d7ad055 refactor: add fallback to 'index' plotting in case of missmatch in length 2025-04-09 14:35:52 +02:00
0e276d4c09 refactor: add support to plot against x_data 2025-04-09 14:35:52 +02:00
ed2d958de6 refactor: improve plotting behaviour from history 2025-04-09 14:35:52 +02:00
25820a1cde refactor: set downsampling to auto=True, method 'peak', activate clipToView for (Async)-Curves and fix ViewAll hook from pg.view_box menu 2025-04-09 14:35:52 +02:00
7f7891dfa5 fix: add support for 'add_slice', add downsampling for performance improvements. add tests 2025-04-09 14:35:52 +02:00
b5015e4e72 built: cleanup gitlab-ci, remove pyqt6 related lines 2025-04-08 14:45:47 +02:00
7653e0877c hack: comment out segfaulting test 2025-04-07 14:19:37 +02:00
52a9f29bdc docs: add docs on widget plugins 2025-04-07 14:19:37 +02:00
ca2bb4f9b4 feat: add loader/helper for widget plugins 2025-04-07 14:19:37 +02:00
119 changed files with 3963 additions and 5044 deletions

View File

@@ -35,8 +35,7 @@ include:
stage: test
path: "."
pytest_args: "-v,--random-order,tests/unit_tests"
ignore_dep_group: "pyqt6"
pip_args: ".[dev,pyside6]"
pip_args: ".[dev]"
# different stages in the pipeline
stages:
@@ -89,7 +88,7 @@ pylint:
needs: []
before_script:
- pip install pylint pylint-exit anybadge
- pip install -e .[dev,pyqt6]
- pip install -e .[dev]
script:
- mkdir ./pylint
- pylint ./bec_widgets --output-format=text --output=./pylint/pylint.log | tee ./pylint/pylint.log || pylint-exit $?

View File

@@ -0,0 +1,21 @@
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
def dock_area(object_name: str | None = None) -> BECDockArea:
_dock_area = BECDockArea(object_name=object_name)
return _dock_area
def auto_update_dock_area(object_name: str | None = None) -> AutoUpdates:
"""
Create a dock area with auto update enabled.
Args:
object_name(str): The name of the dock area.
Returns:
BECDockArea: The created dock area.
"""
_auto_update = AutoUpdates(object_name=object_name)
return _auto_update

View File

@@ -0,0 +1,381 @@
from __future__ import annotations
import os
import xml.etree.ElementTree as ET
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QPainter, QPainterPath, QPixmap
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSizePolicy,
QSpacerItem,
QWidget,
)
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.plugin_utils import get_plugin_auto_updates
from bec_widgets.utils.round_frame import RoundedFrame
from bec_widgets.utils.toolbar import ModularToolBar
from bec_widgets.utils.ui_loader import UILoader
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, UILaunchWindow
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtCore import QObject
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class LaunchTile(RoundedFrame):
open_signal = Signal()
def __init__(
self,
parent: QObject | None = None,
icon_path: str | None = None,
top_label: str | None = None,
main_label: str | None = None,
description: str | None = None,
show_selector: bool = False,
):
super().__init__(parent=parent, orientation="vertical")
self.icon_label = QLabel(parent=self)
self.icon_label.setFixedSize(100, 100)
self.icon_label.setScaledContents(True)
pixmap = QPixmap(icon_path)
if not pixmap.isNull():
size = 100
circular_pixmap = QPixmap(size, size)
circular_pixmap.fill(Qt.transparent)
painter = QPainter(circular_pixmap)
painter.setRenderHints(QPainter.Antialiasing, True)
path = QPainterPath()
path.addEllipse(0, 0, size, size)
painter.setClipPath(path)
pixmap = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
painter.drawPixmap(0, 0, pixmap)
painter.end()
self.icon_label.setPixmap(circular_pixmap)
self.layout.addWidget(self.icon_label, alignment=Qt.AlignCenter)
# Top label
self.top_label = QLabel(top_label.upper())
font_top = self.top_label.font()
font_top.setPointSize(10)
self.top_label.setFont(font_top)
self.layout.addWidget(self.top_label, alignment=Qt.AlignCenter)
# Main label
self.main_label = QLabel(main_label)
font_main = self.main_label.font()
font_main.setPointSize(14)
font_main.setBold(True)
self.main_label.setFont(font_main)
self.main_label.setWordWrap(True)
self.main_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.main_label)
self.spacer_top = QSpacerItem(0, 10, QSizePolicy.Fixed, QSizePolicy.Fixed)
self.layout.addItem(self.spacer_top)
# Description
self.description_label = QLabel(description)
self.description_label.setWordWrap(True)
self.description_label.setAlignment(Qt.AlignCenter)
self.layout.addWidget(self.description_label)
# Selector
if show_selector:
self.selector = QComboBox(self)
self.layout.addWidget(self.selector)
else:
self.selector = None
self.spacer_bottom = QSpacerItem(0, 0, QSizePolicy.Fixed, QSizePolicy.Expanding)
self.layout.addItem(self.spacer_bottom)
# Action button
self.action_button = QPushButton("Open")
self.action_button.setStyleSheet(
"""
QPushButton {
background-color: #007AFF;
border: none;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-weight: bold;
}
QPushButton:hover {
background-color: #005BB5;
}
"""
)
self.layout.addWidget(self.action_button, alignment=Qt.AlignCenter)
class LaunchWindow(BECMainWindow):
RPC = True
TILE_SIZE = (250, 300)
USER_ACCESS = ["show_launcher", "hide_launcher"]
def __init__(
self, parent=None, gui_id: str = None, window_title="BEC Launcher", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.app = QApplication.instance()
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.toolbar = ModularToolBar(parent=self)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
# Main Widget
self.central_widget = QWidget(self)
self.central_widget.layout = QHBoxLayout(self.central_widget)
self.setCentralWidget(self.central_widget)
self.tile_dock_area = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
top_label="Get started",
main_label="BEC Dock Area",
description="Highly flexible and customizable dock area application with modular widgets.",
)
self.tile_dock_area.setFixedSize(*self.TILE_SIZE)
self.tile_auto_update = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "auto_update.png"),
top_label="Get automated",
main_label="BEC Auto Update Dock Area",
description="Dock area with auto update functionality for BEC widgets plotting.",
show_selector=True,
)
self.tile_auto_update.setFixedSize(*self.TILE_SIZE)
self.tile_ui_file = LaunchTile(
icon_path=os.path.join(MODULE_PATH, "assets", "app_icons", "ui_loader_tile.png"),
top_label="Get customized",
main_label="Launch Custom UI File",
description="GUI application with custom UI file.",
)
self.tile_ui_file.setFixedSize(*self.TILE_SIZE)
# Add tiles to the main layout
self.central_widget.layout.addWidget(self.tile_dock_area)
self.central_widget.layout.addWidget(self.tile_auto_update)
self.central_widget.layout.addWidget(self.tile_ui_file)
# hacky solution no time to waste
self.tiles = [self.tile_dock_area, self.tile_auto_update, self.tile_ui_file]
# Connect signals
self.tile_dock_area.action_button.clicked.connect(lambda: self.launch("dock_area"))
self.tile_auto_update.action_button.clicked.connect(self._open_auto_update)
self.tile_ui_file.action_button.clicked.connect(self._open_custom_ui_file)
self._update_theme()
# Auto updates
self.available_auto_updates: dict[str, type[AutoUpdates]] = (
self._update_available_auto_updates()
)
if self.tile_auto_update.selector is not None:
self.tile_auto_update.selector.addItems(
list(self.available_auto_updates.keys()) + ["Default"]
)
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
**kwargs,
) -> QWidget:
"""Launch the specified script. If the launch script creates a QWidget, it will be
embedded in a BECMainWindow. If the launch script creates a BECMainWindow, it will be shown
as a separate window.
Args:
launch_script(str): The name of the script to be launched.
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
QWidget: The created dock area.
"""
from bec_widgets.applications import bw_launch
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
if launch_script is None:
launch_script = "dock_area"
if not isinstance(launch_script, str):
raise ValueError(f"Launch script must be a string, but got {type(launch_script)}.")
if launch_script == "custom_ui_file":
ui_file = kwargs.pop("ui_file", None)
return self._launch_custom_ui_file(ui_file)
if launch_script == "auto_update":
auto_update = kwargs.pop("auto_update", None)
return self._launch_auto_update(auto_update)
launch = getattr(bw_launch, launch_script, None)
if launch is None:
raise ValueError(f"Launch script {launch_script} not found.")
result_widget = launch(name)
result_widget.resize(result_widget.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
result_widget.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
if geometry is not None:
result_widget.setGeometry(*geometry)
if isinstance(result_widget, BECMainWindow):
result_widget.show()
else:
window = BECMainWindow()
window.setCentralWidget(result_widget)
window.show()
return result_widget
def _launch_custom_ui_file(self, ui_file: str | None) -> BECMainWindow:
# Load the custom UI file
if ui_file is None:
raise ValueError("UI file must be provided for custom UI file launch.")
filename = os.path.basename(ui_file).split(".")[0]
tree = ET.parse(ui_file)
root = tree.getroot()
# Check if the top-level widget is a QMainWindow
widget = root.find("widget")
if widget is None:
raise ValueError("No widget found in the UI file.")
if widget.attrib.get("class") == "QMainWindow":
raise ValueError(
"Loading a QMainWindow from a UI file is currently not supported. "
"If you need this, please contact the BEC team or create a ticket on gitlab.psi.ch/bec/bec_widgets."
)
window = UILaunchWindow(object_name=filename)
QApplication.processEvents()
result_widget = UILoader(window).loader(ui_file)
window.setCentralWidget(result_widget)
window.setWindowTitle(f"BEC - {window.object_name}")
window.show()
logger.info(f"Object name of new instance: {result_widget.objectName()}, {window.gui_id}")
return window
def _launch_auto_update(self, auto_update: str) -> AutoUpdates:
if auto_update in self.available_auto_updates:
auto_update_cls = self.available_auto_updates[auto_update]
window = auto_update_cls()
else:
auto_update = "auto_updates"
window = AutoUpdates()
window.resize(window.minimumSizeHint())
QApplication.processEvents()
window.setWindowTitle(f"BEC - {window.objectName()}")
window.show()
return window
def apply_theme(self, theme: str):
"""
Change the theme of the application.
"""
for tile in self.tiles:
tile.apply_theme(theme)
super().apply_theme(theme)
def _open_auto_update(self):
"""
Open the auto update window.
"""
if self.tile_auto_update.selector is None:
auto_update = None
else:
auto_update = self.tile_auto_update.selector.currentText()
if auto_update == "Default":
auto_update = None
return self.launch("auto_update", auto_update=auto_update)
@SafeSlot(popup_error=True)
def _open_custom_ui_file(self):
"""
Open a file dialog to select a custom UI file and launch it.
"""
ui_file, _ = QFileDialog.getOpenFileName(
self, "Select UI File", "", "UI Files (*.ui);;All Files (*)"
)
self.launch("custom_ui_file", ui_file=ui_file)
@staticmethod
def _update_available_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Load all available auto updates from the plugin repository.
"""
try:
auto_updates = get_plugin_auto_updates()
logger.info(f"Available auto updates: {auto_updates.keys()}")
except Exception as exc:
logger.error(f"Failed to load auto updates: {exc}")
return {}
return auto_updates
def show_launcher(self):
"""
Show the launcher window.
"""
self.show()
def hide_launcher(self):
"""
Hide the launcher window.
"""
self.hide()
def showEvent(self, event):
super().showEvent(event)
self.setFixedSize(self.size())
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
launcher = LaunchWindow()
launcher.show()
sys.exit(app.exec())

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1 +0,0 @@
from .client import *

View File

@@ -1,169 +0,0 @@
# TODO autoupdate disabled
# from __future__ import annotations
#
# import threading
# from queue import Queue
# from typing import TYPE_CHECKING
#
# from pydantic import BaseModel
#
# if TYPE_CHECKING:
# from .client import BECDockArea, BECFigure
#
#
# class ScanInfo(BaseModel):
# scan_id: str
# scan_number: int
# scan_name: str
# scan_report_devices: list
# monitored_devices: list
# status: str
# model_config: dict = {"validate_assignment": True}
#
#
# class AutoUpdates:
# create_default_dock: bool = False
# enabled: bool = False
# dock_name: str = None
#
# def __init__(self, gui: BECDockArea):
# self.gui = gui
# self._default_dock = None
# self._default_fig = None
#
# def start_default_dock(self):
# """
# Create a default dock for the auto updates.
# """
# self.dock_name = "default_figure"
# self._default_dock = self.gui.new(self.dock_name)
# self._default_dock.new("BECFigure")
# self._default_fig = self._default_dock.elements_list[0]
#
# @staticmethod
# def get_scan_info(msg) -> ScanInfo:
# """
# Update the script with the given data.
# """
# info = msg.info
# status = msg.status
# scan_id = msg.scan_id
# scan_number = info.get("scan_number", 0)
# scan_name = info.get("scan_name", "Unknown")
# scan_report_devices = info.get("scan_report_devices", [])
# monitored_devices = info.get("readout_priority", {}).get("monitored", [])
# monitored_devices = [dev for dev in monitored_devices if dev not in scan_report_devices]
# return ScanInfo(
# scan_id=scan_id,
# scan_number=scan_number,
# scan_name=scan_name,
# scan_report_devices=scan_report_devices,
# monitored_devices=monitored_devices,
# status=status,
# )
#
# def get_default_figure(self) -> BECFigure | None:
# """
# Get the default figure from the GUI.
# """
# return self._default_fig
#
# def do_update(self, msg):
# """
# Run the update function if enabled.
# """
# if not self.enabled:
# return
# if msg.status != "open":
# return
# info = self.get_scan_info(msg)
# return self.handler(info)
#
# def get_selected_device(self, monitored_devices, selected_device):
# """
# Get the selected device for the plot. If no device is selected, the first
# device in the monitored devices list is selected.
# """
# if selected_device:
# return selected_device
# if len(monitored_devices) > 0:
# sel_device = monitored_devices[0]
# return sel_device
# return None
#
# def handler(self, info: ScanInfo) -> None:
# """
# Default update function.
# """
# if info.scan_name == "line_scan" and info.scan_report_devices:
# return self.simple_line_scan(info)
# if info.scan_name == "grid_scan" and info.scan_report_devices:
# return self.simple_grid_scan(info)
# if info.scan_report_devices:
# return self.best_effort(info)
#
# def simple_line_scan(self, info: ScanInfo) -> None:
# """
# Simple line scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def simple_grid_scan(self, info: ScanInfo) -> None:
# """
# Simple grid scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# dev_y = info.scan_report_devices[1]
# selected_device = yield self.gui.selected_device
# dev_z = self.get_selected_device(info.monitored_devices, selected_device)
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# z_name=dev_z,
# label=f"Scan {info.scan_number} - {dev_z}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )
#
# def best_effort(self, info: ScanInfo) -> None:
# """
# Best effort scan.
# """
# fig = self.get_default_figure()
# if not fig:
# return
# dev_x = info.scan_report_devices[0]
# selected_device = yield self.gui.selected_device
# dev_y = self.get_selected_device(info.monitored_devices, selected_device)
# if not dev_y:
# return
# yield fig.clear_all()
# yield fig.plot(
# x_name=dev_x,
# y_name=dev_y,
# label=f"Scan {info.scan_number} - {dev_y}",
# title=f"Scan {info.scan_number}",
# x_label=dev_x,
# y_label=dev_y,
# )

View File

@@ -1,11 +1,19 @@
# This file was automatically generated by generate_cli.py
# type: ignore
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
import inspect
import traceback
from typing import Literal, Optional
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module
logger = bec_logger.logger
# pylint: skip-file
@@ -17,60 +25,90 @@ class _WidgetsEnumType(str, enum.Enum):
_Widgets = {
"AbortButton": "AbortButton",
"BECColorMapWidget": "BECColorMapWidget",
"BECDockArea": "BECDockArea",
"BECMultiWaveformWidget": "BECMultiWaveformWidget",
"BECProgressBar": "BECProgressBar",
"BECQueue": "BECQueue",
"BECStatusBox": "BECStatusBox",
"DapComboBox": "DapComboBox",
"DarkModeButton": "DarkModeButton",
"DeviceBrowser": "DeviceBrowser",
"DeviceComboBox": "DeviceComboBox",
"DeviceLineEdit": "DeviceLineEdit",
"Image": "Image",
"LMFitDialog": "LMFitDialog",
"LogPanel": "LogPanel",
"Minesweeper": "Minesweeper",
"MotorMap": "MotorMap",
"MultiWaveform": "MultiWaveform",
"PositionIndicator": "PositionIndicator",
"PositionerBox": "PositionerBox",
"PositionerBox2D": "PositionerBox2D",
"PositionerControlLine": "PositionerControlLine",
"ResetButton": "ResetButton",
"ResumeButton": "ResumeButton",
"RingProgressBar": "RingProgressBar",
"ScanControl": "ScanControl",
"ScanMetadata": "ScanMetadata",
"ScatterWaveform": "ScatterWaveform",
"SignalComboBox": "SignalComboBox",
"SignalLineEdit": "SignalLineEdit",
"StopButton": "StopButton",
"TextBox": "TextBox",
"VSCodeEditor": "VSCodeEditor",
"Waveform": "Waveform",
"WebsiteWidget": "WebsiteWidget",
}
Widgets = _WidgetsEnumType("Widgets", _Widgets)
class AbortButton(RPCBase):
"""A button that abort the scan."""
try:
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(
f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !"
)
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \n{reduce(add, traceback.format_exception(e))}")
class BECColorMapWidget(RPCBase):
class AutoUpdates(RPCBase):
@property
@rpc_call
def colormap(self):
def enabled(self) -> "bool":
"""
Get the current colormap name.
Get the enabled status of the auto updates.
"""
@enabled.setter
@rpc_call
def enabled(self) -> "bool":
"""
Get the enabled status of the auto updates.
"""
@property
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@selected_device.setter
@rpc_call
def selected_device(self) -> "str | None":
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
@@ -206,6 +244,8 @@ class BECDock(RPCBase):
class BECDockArea(RPCBase):
"""Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout."""
@property
@rpc_call
def _rpc_id(self) -> "str":
@@ -312,7 +352,8 @@ class BECDockArea(RPCBase):
@rpc_call
def remove(self) -> "None":
"""
Remove the dock area.
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
@rpc_call
@@ -333,13 +374,6 @@ class BECDockArea(RPCBase):
Return all floating docks to the dock area.
"""
@property
@rpc_call
def selected_device(self) -> "str":
"""
None
"""
@rpc_call
def save_state(self) -> "dict":
"""
@@ -363,14 +397,6 @@ class BECDockArea(RPCBase):
"""
class BECMainWindow(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class BECProgressBar(RPCBase):
"""A custom progress bar with smooth transitions. The displayed text can be customized using a template."""
@@ -468,6 +494,15 @@ class Curve(RPCBase):
dict: The configuration of the widget.
"""
@rpc_call
def _get_displayed_data(self) -> "tuple[np.ndarray, np.ndarray]":
"""
Get the displayed data of the curve.
Returns:
tuple[np.ndarray, np.ndarray]: The x and y data of the curve.
"""
@rpc_call
def set(self, **kwargs):
"""
@@ -563,7 +598,7 @@ class Curve(RPCBase):
"""
@rpc_call
def get_data(self) -> "tuple[np.ndarray, np.ndarray]":
def get_data(self) -> "tuple[np.ndarray | None, np.ndarray | None]":
"""
Get the data of the curve.
Returns:
@@ -630,16 +665,9 @@ class DapComboBox(RPCBase):
"""
class DarkModeButton(RPCBase):
@rpc_call
def toggle_dark_mode(self) -> "None":
"""
Toggle the dark mode state. This will change the theme of the entire
application to dark or light mode.
"""
class DeviceBrowser(RPCBase):
"""DeviceBrowser is a widget that displays all available devices in the current BEC session."""
@rpc_call
def remove(self):
"""
@@ -677,17 +705,9 @@ class DeviceLineEdit(RPCBase):
"""
class DeviceSignalInputBase(RPCBase):
"""Mixin base class for device signal input widgets."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class Image(RPCBase):
"""Image widget for displaying 2D data."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -1346,16 +1366,6 @@ class ImageItem(RPCBase):
"""
class LMFitDialog(RPCBase):
"""Dialog for displaying the fit summary and params for LMFit DAP processes"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class LogPanel(RPCBase):
"""Displays a log panel"""
@@ -1378,10 +1388,9 @@ class LogPanel(RPCBase):
"""
class Minesweeper(RPCBase): ...
class MotorMap(RPCBase):
"""Motor map widget for plotting motor positions in 2D including a trace of the last points."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -1771,6 +1780,8 @@ class MotorMap(RPCBase):
class MultiWaveform(RPCBase):
"""MultiWaveform widget for displaying multiple waveforms emitted by a single signal."""
@property
@rpc_call
def enable_toolbar(self) -> "bool":
@@ -2180,6 +2191,8 @@ class MultiWaveform(RPCBase):
class PositionIndicator(RPCBase):
"""Display a position within a defined range, e.g. motor limits."""
@rpc_call
def set_value(self, position: float):
"""
@@ -2218,64 +2231,6 @@ class PositionIndicator(RPCBase):
"""
class PositionerBox(RPCBase):
"""Simple Widget to control a positioner in box form"""
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerBox2D(RPCBase):
"""Simple Widget to control two positioners in box form"""
@rpc_call
def set_positioner_hor(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
@rpc_call
def set_positioner_ver(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerBoxBase(RPCBase):
"""Contains some core logic for positioner box widgets"""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class PositionerControlLine(RPCBase):
"""A widget that controls a single device."""
@rpc_call
def set_positioner(self, positioner: "str | Positioner"):
"""
Set the device
Args:
positioner (Positioner | str) : Positioner to set, accepts str or the device
"""
class PositionerGroup(RPCBase):
"""Simple Widget to control a positioner in box form"""
@@ -2288,26 +2243,6 @@ class PositionerGroup(RPCBase):
"""
class ResetButton(RPCBase):
"""A button that resets the scan queue."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ResumeButton(RPCBase):
"""A button that continue scan queue."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class Ring(RPCBase):
@rpc_call
def _get_all_rpc(self) -> "dict":
@@ -2409,6 +2344,8 @@ class Ring(RPCBase):
class RingProgressBar(RPCBase):
"""Show the progress of devices, scans or custom values in the form of ring progress bars."""
@rpc_call
def _get_all_rpc(self) -> "dict":
"""
@@ -2588,15 +2525,7 @@ class RingProgressBar(RPCBase):
class ScanControl(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class ScanMetadata(RPCBase):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the"""
"""Widget to submit new scans to the queue."""
@rpc_call
def remove(self):
@@ -2958,36 +2887,6 @@ class ScatterWaveform(RPCBase):
"""
class SignalComboBox(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class SignalLineEdit(RPCBase):
"""Line edit widget for device input with autocomplete for device names."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class StopButton(RPCBase):
"""A button that stops the current scan."""
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class TextBox(RPCBase):
"""A widget that displays text in plain and HTML format"""
@@ -3010,6 +2909,14 @@ class TextBox(RPCBase):
"""
class UILaunchWindow(RPCBase):
@rpc_call
def remove(self):
"""
Cleanup the BECConnector
"""
class VSCodeEditor(RPCBase):
"""A widget to display the VSCode editor."""
@@ -3017,6 +2924,8 @@ class VSCodeEditor(RPCBase):
class Waveform(RPCBase):
"""Widget for plotting waveforms."""
@property
@rpc_call
def _config_dict(self) -> "dict":
@@ -3298,12 +3207,6 @@ class Waveform(RPCBase):
The font size of the legend font.
"""
@rpc_call
def __getitem__(self, key: "int | str"):
"""
None
"""
@property
@rpc_call
def curves(self) -> "list[Curve]":

View File

@@ -10,11 +10,11 @@ import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
@@ -22,13 +22,17 @@ import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
if TYPE_CHECKING: # pragma: no cover
from bec_lib.redis_connector import StreamMessage
from bec_lib.messages import GUIRegistryStateMessage
else:
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
logger = bec_logger.logger
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
]
# pylint: disable=redefined-outer-scope
@@ -67,7 +71,11 @@ def _get_output(process, logger) -> None:
def _start_plot_process(
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
gui_id: str,
gui_class_id: str,
config: dict | str,
gui_class: str = "dock_area",
logger=None, # FIXME change gui_class back to "launcher" later
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
"""
Start the plot in a new process.
@@ -82,7 +90,7 @@ def _start_plot_process(
"--id",
gui_id,
"--gui_class",
gui_class.__name__,
gui_class,
"--gui_class_id",
gui_class_id,
"--hide",
@@ -195,25 +203,27 @@ class BECGuiClient(RPCBase):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._lock = Lock()
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._anchor_widget = "launcher"
self._killed = False
self._top_level: dict[str, client.BECDockArea] = {}
self._top_level: dict[str, RPCReference] = {}
self._startup_timeout = 0
self._gui_started_timer = None
self._gui_started_event = threading.Event()
self._process = None
self._process_output_processing_thread = None
self._exposed_widgets = []
self._server_registry = {}
self._ipython_registry = {}
self._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
####################
#### Client API ####
####################
@property
def launcher(self) -> RPCBase:
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
@@ -221,21 +231,25 @@ class BECGuiClient(RPCBase):
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
self._gui_id = gui_id
# Get the registry state
msgs = self._client.connector.xread(
MessageEndpoints.gui_registry_state(self._gui_id), count=1
)
if msgs:
self._handle_registry_update(msgs[0])
# reset the namespace
self._update_dynamic_namespace({})
self._server_registry = {}
self._top_level = {}
self._ipython_registry = {}
# Register the new callback
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
@property
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return self._top_level
return {widget.object_name: widget for widget in self._top_level.values()}
@property
def window_list(self) -> list:
@@ -275,13 +289,11 @@ class BECGuiClient(RPCBase):
self.start(wait=True)
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
widget = self.launcher._run_rpc(
"launch", "dock_area", name, geometry
) # pylint: disable=protected-access
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
widget = self.launcher._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
@@ -352,11 +364,13 @@ class BECGuiClient(RPCBase):
# Wait for 'bec' gui to be registered, this may take some time
# After 60s timeout. Should this raise an exception on timeout?
while time.time() < time.time() + timeout:
if len(list(self._server_registry.keys())) == 0:
if len(list(self._server_registry.keys())) < 2 or not hasattr(
self, self._anchor_widget
):
time.sleep(0.1)
else:
break
self._do_show_all()
self._gui_started_event.set()
def _start_server(self, wait: bool = False) -> None:
@@ -369,8 +383,7 @@ class BECGuiClient(RPCBase):
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
self.__class__,
gui_class_id=self._default_dock_name,
gui_class_id="bec",
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
@@ -380,7 +393,7 @@ class BECGuiClient(RPCBase):
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
threading.current_thread().cancel() # type: ignore
self._gui_started_timer = RepeatTimer(
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
@@ -390,25 +403,27 @@ class BECGuiClient(RPCBase):
if wait:
self._gui_started_event.wait()
def _dump(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
return rpc_client._run_rpc("_dump")
def _start(self, wait: bool = False) -> None:
self._killed = False
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
)
return self._start_server(wait=wait)
def _handle_registry_update(self, msg: StreamMessage) -> None:
@staticmethod
def _handle_registry_update(
msg: dict[str, GUIRegistryStateMessage], parent: BECGuiClient
) -> None:
# This was causing a deadlock during shutdown, not sure why.
# with self._lock:
self._server_registry = msg["data"].state
self._update_dynamic_namespace()
self = parent
self._server_registry = cast(dict[str, RegistryState], msg["data"].state)
self._update_dynamic_namespace(self._server_registry)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
@@ -419,124 +434,72 @@ class BECGuiClient(RPCBase):
def _hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
rpc_client._run_rpc("hide") # pylint: disable=protected-access
if not self._killed:
for window in self._top_level.values():
window.hide()
def _update_dynamic_namespace(self):
"""Update the dynamic name space"""
# Clear the top level
self._top_level.clear()
# First we update the name space based on the new registry state
self._add_registry_to_namespace()
# Then we clear the ipython registry from old objects
self._cleanup_ipython_registry()
def _update_dynamic_namespace(self, server_registry: dict):
"""
Update the dynamic name space with the given server registry.
Setting the server registry to an empty dictionary will remove all widgets from the namespace.
def _cleanup_ipython_registry(self):
"""Cleanup the ipython registry"""
names_in_registry = list(self._ipython_registry.keys())
names_in_server_state = list(self._server_registry.keys())
remove_ids = list(set(names_in_registry) - set(names_in_server_state))
for widget_id in remove_ids:
self._ipython_registry.pop(widget_id)
self._cleanup_rpc_references_on_rpc_base(remove_ids)
# Clear the exposed widgets
self._exposed_widgets.clear() # No longer needed I think
Args:
server_registry (dict): The server registry
"""
top_level_widgets: dict[str, RPCReference] = {}
for gui_id, state in server_registry.items():
widget = self._add_widget(state, self)
if widget is None:
# ignore widgets that are not supported
continue
# get all top-level widgets. These are widgets that have no parent
if not state["config"].get("parent_id"):
top_level_widgets[gui_id] = widget
def _cleanup_rpc_references_on_rpc_base(self, remove_ids: list[str]) -> None:
"""Cleanup the rpc references on the RPCBase object"""
if not remove_ids:
return
for widget in self._ipython_registry.values():
to_delete = []
for attr_name, gui_id in widget._rpc_references.items():
if gui_id in remove_ids:
to_delete.append(attr_name)
for attr_name in to_delete:
if hasattr(widget, attr_name):
delattr(widget, attr_name)
if attr_name.startswith("elements."):
delattr(widget.elements, attr_name.split(".")[1])
widget._rpc_references.pop(attr_name)
remove_from_registry = []
for gui_id, widget in self._ipython_registry.items():
if gui_id not in server_registry:
remove_from_registry.append(gui_id)
for gui_id in remove_from_registry:
self._ipython_registry.pop(gui_id)
def _set_dynamic_attributes(self, obj: object, name: str, value: Any) -> None:
"""Add an object to the namespace"""
setattr(obj, name, value)
def _update_rpc_references(self, widget: RPCBase, name: str, gui_id: str) -> None:
"""Update the RPC references"""
widget._rpc_references[name] = gui_id
def _add_registry_to_namespace(self) -> None:
"""Add registry to namespace"""
# Add dock areas
dock_area_states = [
state
for state in self._server_registry.values()
if state["widget_class"] == "BECDockArea"
removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
]
for state in dock_area_states:
dock_area_ref = self._add_widget(state, self)
dock_area = self._ipython_registry.get(dock_area_ref._gui_id)
if not hasattr(dock_area, "elements"):
self._set_dynamic_attributes(dock_area, "elements", WidgetNameSpace())
self._set_dynamic_attributes(self, dock_area.widget_name, dock_area_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(self, dock_area.widget_name, dock_area_ref._gui_id)
# Add dock_area to the top level
self._top_level[dock_area_ref.widget_name] = dock_area_ref
self._exposed_widgets.append(dock_area_ref._gui_id)
# Add docks
dock_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_area_ref._gui_id
]
for state in dock_states:
dock_ref = self._add_widget(state, dock_area)
dock = self._ipython_registry.get(dock_ref._gui_id)
self._set_dynamic_attributes(dock_area, dock_ref.widget_name, dock_ref)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(dock_area, dock_ref.widget_name, dock_ref._gui_id)
# Keep track of exposed docks
self._exposed_widgets.append(dock_ref._gui_id)
for widget_name in removed_widgets:
# the check is not strictly necessary, but better safe
# than sorry; who knows what the user has done
if hasattr(self, widget_name):
delattr(self, widget_name)
# Add widgets
widget_states = [
state
for state in self._server_registry.values()
if state["config"].get("parent_id", "") == dock_ref._gui_id
]
for state in widget_states:
widget_ref = self._add_widget(state, dock)
self._set_dynamic_attributes(dock, widget_ref.widget_name, widget_ref)
self._set_dynamic_attributes(
dock_area.elements, widget_ref.widget_name, widget_ref
)
# Keep track of rpc references on RPCBase object
self._update_rpc_references(
dock_area, f"elements.{widget_ref.widget_name}", widget_ref._gui_id
)
self._update_rpc_references(dock, widget_ref.widget_name, widget_ref._gui_id)
# Keep track of exposed widgets
self._exposed_widgets.append(widget_ref._gui_id)
for gui_id, widget_ref in top_level_widgets.items():
setattr(self, widget_ref.object_name, widget_ref)
def _add_widget(self, state: dict, parent: object) -> RPCReference:
self._top_level = top_level_widgets
for widget in self._ipython_registry.values():
widget._refresh_references()
def _add_widget(self, state: dict, parent: object) -> RPCReference | None:
"""Add a widget to the namespace
Args:
state (dict): The state of the widget from the _server_registry.
parent (object): The parent object.
"""
name = state["name"]
object_name = state["object_name"]
gui_id = state["gui_id"]
widget_class = getattr(client, state["widget_class"])
if state["widget_class"] in IGNORE_WIDGETS:
return
widget_class = getattr(client, state["widget_class"], None)
if widget_class is None:
return
obj = self._ipython_registry.get(gui_id)
if obj is None:
widget = widget_class(gui_id=gui_id, name=name, parent=parent)
widget = widget_class(gui_id=gui_id, object_name=object_name, parent=parent)
self._ipython_registry[gui_id] = widget
else:
widget = obj

View File

@@ -34,13 +34,27 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py\n
def __init__(self, base=False):
self._base = base
base_imports = (
"""import enum
import inspect
import traceback
from typing import Literal, Optional
"""
if self._base
else "\n"
)
self.header = f"""# This file was automatically generated by generate_cli.py
# type: ignore \n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
{base_imports}
from bec_lib.logger import bec_logger
from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
{"from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets, get_plugin_client_module" if self._base else ""}
logger = bec_logger.logger
# pylint: skip-file"""
@@ -67,6 +81,7 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
self.write_client_enum(rpc_top_level_classes)
for cls in connector_classes:
logger.debug(f"generating RPC client class for {cls.__name__}")
self.content += "\n\n"
self.generate_content_for_class(cls)
@@ -74,10 +89,14 @@ from bec_widgets.cli.rpc.rpc_base import RPCBase, rpc_call
"""
Write the client enum to the content.
"""
self.content += """
if self._base:
self.content += """
class _WidgetsEnumType(str, enum.Enum):
\"\"\" Enum for the available widgets, to be generated programatically \"\"\"
...
"""
self.content += """
_Widgets = {
"""
@@ -85,8 +104,35 @@ _Widgets = {
self.content += f'"{cls.__name__}": "{cls.__name__}",\n '
self.content += """}
Widgets = _WidgetsEnumType("Widgets", _Widgets)
"""
"""
if self._base:
self.content += """
try:
_plugin_widgets = get_all_plugin_widgets()
plugin_client = get_plugin_client_module()
Widgets = _WidgetsEnumType("Widgets", {name: name for name in _plugin_widgets} | _Widgets)
if (_overlap := _Widgets.keys() & _plugin_widgets.keys()) != set():
for _widget in _overlap:
logger.warning(f"Detected duplicate widget {_widget} in plugin repo file: {inspect.getfile(_plugin_widgets[_widget])} !")
for plugin_name, plugin_class in inspect.getmembers(plugin_client, inspect.isclass):
if issubclass(plugin_class, RPCBase) and plugin_class is not RPCBase:
if plugin_name in globals():
conflicting_file = (
inspect.getfile(_plugin_widgets[plugin_name])
if plugin_name in _plugin_widgets
else f"{plugin_client}"
)
logger.warning(
f"Plugin widget {plugin_name} from {conflicting_file} conflicts with a built-in class!"
)
continue
if plugin_name not in _overlap:
globals()[plugin_name] = plugin_class
except ImportError as e:
logger.error(f"Failed loading plugins: \\n{reduce(add, traceback.format_exception(e))}")
"""
def generate_content_for_class(self, cls):
"""
@@ -199,38 +245,59 @@ def main():
parser = argparse.ArgumentParser(description="Auto-generate the client for RPC widgets")
parser.add_argument(
"--module-name",
"--target",
action="store",
type=str,
default="bec_widgets",
help="Which module to generate plugin files for (default: bec_widgets, example: my_plugin_repo.bec_widgets)",
help="Which package to generate plugin files for. Should be installed in the local environment (example: my_plugin_repo)",
)
args = parser.parse_args()
if args.target is None:
logger.error(
"You must provide a target - for safety, the default of running this on bec_widgets core has been removed. To generate the client for bec_widgets, run `bw-generate-cli --target bec_widgets`"
)
return
logger.info(f"BEC Widget code generation tool started with args: {args}")
client_subdir = "cli" if args.target == "bec_widgets" else "widgets"
module_name = "bec_widgets" if args.target == "bec_widgets" else f"{args.target}.bec_widgets"
try:
module = importlib.import_module(args.module_name)
module = importlib.import_module(module_name)
assert module.__file__ is not None
module_file = Path(module.__file__)
module_dir = module_file.parent if module_file.is_file() else module_file
except Exception as e:
logger.error(f"Failed to load module {args.module_name} for code generation: {e}")
logger.error(f"Failed to load module {module_name} for code generation: {e}")
return
client_path = module_dir / "client.py"
client_path = module_dir / client_subdir / "client.py"
rpc_classes = get_custom_classes(args.module_name)
rpc_classes = get_custom_classes(module_name)
logger.info(f"Obtained classes with RPC objects: {rpc_classes!r}")
generator = ClientGenerator(base=args.module_name == "bec_widgets")
logger.info(f"Generating client.py")
generator = ClientGenerator(base=module_name == "bec_widgets")
logger.info(f"Generating client file at {client_path}")
generator.generate_client(rpc_classes)
generator.write(str(client_path))
if module_name != "bec_widgets":
non_overwrite_classes = list(clsinfo.name for clsinfo in get_custom_classes("bec_widgets"))
logger.info(
f"Not writing plugins which would conflict with builtin classes: {non_overwrite_classes}"
)
else:
non_overwrite_classes = []
for cls in rpc_classes.plugins:
logger.info(f"Writing plugins for: {cls}")
logger.info(f"Writing bec-designer plugin files for {cls.__name__}...")
if cls.__name__ in non_overwrite_classes:
logger.error(
f"Not writing plugin files for {cls.__name__} because a built-in widget with that name exists"
)
plugin = DesignerPluginGenerator(cls)
if not hasattr(plugin, "info"):
continue
@@ -239,7 +306,9 @@ def main():
return os.path.exists(os.path.join(plugin.info.base_path, file))
if any(_exists(file) for file in plugin_filenames(plugin.info.plugin_name_snake)):
logger.debug(f"Skipping {plugin.info.plugin_name_snake} - a file already exists.")
logger.debug(
f"Skipping generation of extra plugin files for {plugin.info.plugin_name_snake} - at least one file out of 'plugin.py', 'pyproject', and 'register_{plugin.info.plugin_name_snake}.py' already exists."
)
continue
plugin.run()

View File

@@ -4,20 +4,21 @@ import inspect
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
import bec_widgets.cli.client as client
if TYPE_CHECKING: # pragma: no cover
from bec_lib import messages
from bec_lib.connector import MessageObject
import bec_widgets.cli.client as client
from bec_widgets.cli.client_utils import BECGuiClient
else:
client = lazy_import("bec_widgets.cli.client") # avoid circular import
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
@@ -38,7 +39,7 @@ def rpc_call(func):
def wrapper(self, *args, **kwargs):
# we could rely on a strict type check here, but this is more flexible
# moreover, it would anyway crash for objects...
caller_frame = inspect.currentframe().f_back
caller_frame = inspect.currentframe().f_back # type: ignore
while caller_frame:
if "jedi" in caller_frame.f_globals:
# Jedi module is present, likely tab completion
@@ -88,16 +89,20 @@ class RPCReference:
def __init__(self, registry: dict, gui_id: str) -> None:
self._registry = registry
self._gui_id = gui_id
self.object_name = self._registry[self._gui_id].object_name
@check_for_deleted_widget
def __getattr__(self, name):
if name in ["_registry", "_gui_id"]:
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
@check_for_deleted_widget
def __getitem__(self, key):
return self._registry[self._gui_id].__getitem__(key)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "object_name"]:
return super().__setattr__(name, value)
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
self._registry[self._gui_id].__setattr__(name, value)
def __repr__(self):
if self._gui_id not in self._registry:
@@ -114,19 +119,22 @@ class RPCReference:
return []
return self._registry[self._gui_id].__dir__()
def _is_deleted(self) -> bool:
return self._gui_id not in self._registry
class RPCBase:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
name: str | None = None,
object_name: str | None = None,
parent=None,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self.object_name = object_name if object_name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
@@ -149,10 +157,10 @@ class RPCBase:
"""
Get the widget name.
"""
return self._name
return self.object_name
@property
def _root(self):
def _root(self) -> BECGuiClient:
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
@@ -161,9 +169,9 @@ class RPCBase:
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent
return parent # type: ignore
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=5, **kwargs) -> Any:
"""
Run the RPC call.
@@ -205,7 +213,11 @@ class RPCBase:
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# get class name
# we can assume that the response is a RequestResponseMessage, updated by
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
@@ -213,10 +225,10 @@ class RPCBase:
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
parent._rpc_response = msg
parent._msg_wait_event.set()
def _create_widget_from_msg_result(self, msg_result):
if msg_result is None:
@@ -236,13 +248,14 @@ class RPCBase:
cls = getattr(client, cls)
# The namespace of the object will be updated dynamically on the client side
# Therefor it is important to check if the object is already in the registry
# Therefore it is important to check if the object is already in the registry
# If yes, we return the reference to the object, otherwise we create a new object
# pylint: disable=protected-access
if msg_result["gui_id"] in self._root._ipython_registry:
return RPCReference(self._root._ipython_registry, msg_result["gui_id"])
ret = cls(parent=self, **msg_result)
self._root._ipython_registry[ret._gui_id] = ret
self._refresh_references()
obj = RPCReference(self._root._ipython_registry, ret._gui_id)
return obj
# return ret
@@ -258,3 +271,27 @@ class RPCBase:
if heart.status == messages.BECStatus.RUNNING:
return True
return False
def _refresh_references(self):
"""
Refresh the references.
"""
with self._root._lock:
references = {}
for key, val in self._root._server_registry.items():
parent_id = val["config"].get("parent_id")
if parent_id == self._gui_id:
references[key] = {
"gui_id": val["config"]["gui_id"],
"object_name": val["object_name"],
}
removed_references = set(self._rpc_references.keys()) - set(references.keys())
for key in removed_references:
delattr(self, self._rpc_references[key]["object_name"])
self._rpc_references = references
for key, val in references.items():
setattr(
self,
val["object_name"],
RPCReference(self._root._ipython_registry, val["gui_id"]),
)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from functools import wraps
from threading import Lock, RLock
from threading import RLock
from typing import TYPE_CHECKING, Callable
from weakref import WeakValueDictionary
@@ -65,7 +65,7 @@ class RPCRegister:
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: QObject):
def add_rpc(self, rpc: BECConnector):
"""
Add an RPC object to the register.
@@ -77,7 +77,7 @@ class RPCRegister:
self._rpc_register[rpc.gui_id] = rpc
@broadcast_update
def remove_rpc(self, rpc: str):
def remove_rpc(self, rpc: BECConnector):
"""
Remove an RPC object from the register.
@@ -113,7 +113,7 @@ class RPCRegister:
return connections
def get_names_of_rpc_by_class_type(
self, cls: BECWidget | BECConnector | BECDock | BECDockArea
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
) -> list[str]:
"""Get all the names of the widgets.
@@ -123,7 +123,7 @@ class RPCRegister:
# This retrieves any rpc objects that are subclass of BECWidget,
# i.e. curve and image items are excluded
widgets = [rpc for rpc in self._rpc_register.values() if isinstance(rpc, cls)]
return [widget._name for widget in widgets]
return [widget.object_name for widget in widgets]
def broadcast(self):
"""
@@ -136,6 +136,18 @@ class RPCRegister:
for callback in self.callbacks:
callback(connections)
def object_is_registered(self, obj: BECConnector) -> bool:
"""
Check if an object is registered in the RPC register.
Args:
obj(QObject): The object to check.
Returns:
bool: True if the object is registered, False otherwise.
"""
return obj.gui_id in self._rpc_register
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.
@@ -170,7 +182,8 @@ class RPCRegisterBroadcast:
def __exit__(self, *exc):
"""Exit the context manager"""
self._call_depth -= 1 # Remove nested calls
if self._call_depth == 0: # Last one to exit is repsonsible for broadcasting
if self._call_depth == 0: # The Last one to exit is responsible for broadcasting
self.rpc_register._skip_broadcast = False
self.rpc_register.broadcast()

View File

@@ -1,9 +1,9 @@
from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.plugin_utils import get_custom_classes
class RPCWidgetHandler:
@@ -31,14 +31,12 @@ class RPCWidgetHandler:
Returns:
None
"""
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {
self._widget_classes = get_all_plugin_widgets() | {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
def create_widget(self, widget_type, **kwargs) -> BECWidget:
"""
Create a widget from an RPC message.
@@ -52,7 +50,7 @@ class RPCWidgetHandler:
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(name=name, **kwargs)
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,194 +1,34 @@
from __future__ import annotations
import functools
import argparse
import json
import os
import signal
import sys
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.utils.bec_dispatcher import BECDispatcher
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class BECWidgetsCLIServer:
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: type[BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self.status = messages.BECStatus.RUNNING
with RPCRegister.delayed_broadcast():
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception as e:
logger.error(f"Error while executing RPC instruction: {e}")
self.send_response(request_id, False, {"error": str(e)})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj):
if isinstance(obj, BECConnector):
config = obj.config.model_dump()
config["parent_id"] = obj.parent_id # add parent_id to config
return {
"gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__,
"config": config,
"__rpc__": True,
}
return obj
def emit_heartbeat(self):
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict):
"""
Broadcast the updated registry to all clients.
"""
# We only need to broadcast the dock areas
data = {key: self.serialize_object(val) for key, val in connections.items()}
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1, # only single message in stream
)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down gui")
self.client.shutdown()
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class SimpleFileLikeFromLogOutputFunc:
def __init__(self, log_func):
self._log_func = log_func
self._buffer = []
self.encoding = "utf8"
def write(self, buffer):
self._buffer.append(buffer)
@@ -203,40 +43,139 @@ class SimpleFileLikeFromLogOutputFunc:
return
def _start_server(
gui_id: str, gui_class: BECDockArea, gui_class_id: str = "bec", config: str | None = None
):
if config:
try:
config = json.loads(config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
class GUIServer:
"""
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
"""
# bec_logger.configure(
# service_config.redis,
# QtRedisConnector,
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
)
return server
def __init__(self, args):
self.config = args.config
self.gui_id = args.id
self.gui_class = args.gui_class
self.gui_class_id = args.gui_class_id
self.hide = args.hide
self.app: QApplication | None = None
self.launcher_window: LaunchWindow | None = None
self.dispatcher: BECDispatcher | None = None
def start(self):
"""
Start the GUI server.
"""
bec_logger.level = bec_logger.LOGLEVEL.INFO
if self.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)): # type: ignore
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)): # type: ignore
self._run()
def _get_service_config(self) -> ServiceConfig:
if self.config:
try:
config = json.loads(self.config)
service_config = ServiceConfig(config=config)
except (json.JSONDecodeError, TypeError):
service_config = ServiceConfig(config_path=config)
else:
# if no config is provided, use the default config
service_config = ServiceConfig()
return service_config
def _turn_off_the_lights(self, connections: dict):
"""
If there is only one connection remaining, it is the launcher, so we show it.
Once the launcher is closed as the last window, we quit the application.
"""
self.launcher_window = cast(LaunchWindow, self.launcher_window)
remaining_connections = [
connection
for connection in connections.values()
if connection.parent_id != self.launcher_window.gui_id
]
if len(remaining_connections) <= 1:
self.launcher_window.show()
self.launcher_window.activateWindow()
self.launcher_window.raise_()
if self.app:
self.app.setQuitOnLastWindowClosed(True)
else:
self.launcher_window.hide()
if self.app:
self.app.setQuitOnLastWindowClosed(False)
def _run(self):
"""
Run the GUI server.
"""
self.app = QApplication(sys.argv)
self.app.setApplicationName("BEC")
self.app.gui_id = self.gui_id # type: ignore
self.setup_bec_icon()
service_config = self._get_service_config()
self.dispatcher = BECDispatcher(config=service_config, gui_id=self.gui_id)
# self.dispatcher.start_cli_server(gui_id=self.gui_id)
self.launcher_window = LaunchWindow(gui_id=f"{self.gui_id}:launcher")
self.launcher_window.setAttribute(Qt.WA_ShowWithoutActivating) # type: ignore
self.app.aboutToQuit.connect(self.shutdown)
self.app.setQuitOnLastWindowClosed(False)
register = RPCRegister()
register.callbacks.append(self._turn_off_the_lights)
register.broadcast()
if self.gui_class:
# If the server is started with a specific gui class, we launch it.
# This will automatically hide the launcher.
self.launcher_window.launch(self.gui_class, name=self.gui_class_id)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets(): # type: ignore
widget.close()
if self.app:
self.app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(self.app.exec())
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
if self.app is None:
return
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
def shutdown(self):
"""
Shutdown the GUI server.
"""
if self.dispatcher:
self.dispatcher.stop_cli_server()
self.dispatcher.disconnect_all()
def main():
import argparse
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
"""
Main entry point for subprocesses that start a GUI server.
"""
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, default="test", help="The id of the server")
@@ -256,69 +195,12 @@ def main():
args = parser.parse_args()
bec_logger.level = bec_logger.LOGLEVEL.INFO
if args.hide:
# pylint: disable=protected-access
bec_logger._stderr_log_level = bec_logger.LOGLEVEL.ERROR
bec_logger._update_sinks()
if args.gui_class == "BECDockArea":
gui_class = BECDockArea
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECDockArea
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
# set close on last window, only if not under control of client ;
# indeed, Qt considers a hidden window a closed window, so if all windows
# are hidden by default it exits
app.setQuitOnLastWindowClosed(not args.hide)
module_path = os.path.dirname(bec_widgets.__file__)
icon = QIcon()
icon.addFile(
os.path.join(module_path, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
app.setWindowIcon(icon)
# store gui id within QApplication object, to make it available to all widgets
app.gui_id = args.id
# args.id = "abff6"
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
if not args.hide:
win.show()
app.aboutToQuit.connect(server.shutdown)
def sigint_handler(*args):
# display message, for people to let it terminate gracefully
print("Caught SIGINT, exiting")
# Widgets should be all closed.
with RPCRegister.delayed_broadcast():
for widget in QApplication.instance().topLevelWidgets():
widget.close()
app.quit()
# gui.bec.close()
# win.shutdown()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
server = GUIServer(args)
server.start()
if __name__ == "__main__":
# import sys
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
main()

File diff suppressed because it is too large Load Diff

View File

@@ -10,21 +10,22 @@ from typing import TYPE_CHECKING, Optional
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from pydantic import BaseModel, Field, field_validator
from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.error_popups import ErrorPopupUtility
from bec_widgets.utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.widgets.containers.dock import BECDock
else:
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
class ConnectionConfig(BaseModel):
@@ -82,14 +83,27 @@ class BECConnector:
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
name: str | None = None,
parent_dock: BECDock | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
parent_id: str | None = None,
**kwargs,
):
# Extract object_name from kwargs to not pass it to Qt class
object_name = object_name or kwargs.pop("objectName", None)
# Ensure the parent is always the first argument for QObject
parent = kwargs.pop("parent", None)
# This initializes the QObject or any qt related class BECConnector has to be used from this line down with QObject, otherwise hierarchy logic will not work
super().__init__(parent=parent, **kwargs)
assert isinstance(
self, QObject
), "BECConnector must be used with a QObject or any qt related class."
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
self._parent_dock = parent_dock
self._parent_dock = parent_dock # TODO also remove at some point -> issue created #473
self.rpc_register = RPCRegister()
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
@@ -122,14 +136,21 @@ class BECConnector:
self.gui_id: str = gui_id # Keep namespace in sync
else:
self.gui_id: str = self.config.gui_id # type: ignore
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
if object_name is not None:
self.setObjectName(object_name)
# 1) If no objectName is set, set the initial name
if not self.objectName():
self.setObjectName(self.__class__.__name__)
self.object_name = self.objectName()
# 2) Enforce unique objectName among siblings with the same BECConnector parent
self.setParent(parent)
if parent_id is None:
connector_parent = WidgetHierarchy._get_becwidget_ancestor(self)
if connector_parent is not None:
self.parent_id = connector_parent.gui_id
# Error popups
self.error_utility = ErrorPopupUtility()
@@ -138,6 +159,75 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
QTimer.singleShot(0, self._update_object_name)
def _update_object_name(self) -> None:
"""
Enforce a unique object name among siblings and register the object for RPC.
This method is called through a single shot timer kicked off in the constructor.
"""
# 1) Enforce unique objectName among siblings with the same BECConnector parent
self._enforce_unique_sibling_name()
# 2) Register the object for RPC
self.rpc_register.add_rpc(self)
def _enforce_unique_sibling_name(self):
"""
Enforce that this BECConnector has a unique objectName among its siblings.
Sibling logic:
- If there's a nearest BECConnector parent, only compare with children of that parent.
- If parent is None (i.e., top-level object), compare with all other top-level BECConnectors.
"""
QApplication.processEvents()
parent_bec = WidgetHierarchy._get_becwidget_ancestor(self)
if parent_bec:
# We have a parent => only compare with siblings under that parent
siblings = parent_bec.findChildren(BECConnector)
else:
# No parent => treat all top-level BECConnectors as siblings
# 1) Gather all BECConnectors from QApplication
all_widgets = QApplication.allWidgets()
all_bec = [w for w in all_widgets if isinstance(w, BECConnector)]
# 2) "Top-level" means closest BECConnector parent is None
top_level_bec = [
w for w in all_bec if WidgetHierarchy._get_becwidget_ancestor(w) is None
]
# 3) We are among these top-level siblings
siblings = top_level_bec
# Collect used names among siblings
used_names = {sib.objectName() for sib in siblings if sib is not self}
base_name = self.object_name
if base_name not in used_names:
# Name is already unique among siblings
return
# Need a suffix to avoid collision
counter = 0
while True:
trial_name = f"{base_name}_{counter}"
if trial_name not in used_names:
self.setObjectName(trial_name)
self.object_name = trial_name
break
counter += 1
# pylint: disable=invalid-name
def setObjectName(self, name: str) -> None:
"""
Set the object name of the widget.
Args:
name (str): The new object name.
"""
super().setObjectName(name)
self.object_name = name
if self.rpc_register.object_is_registered(self):
self.rpc_register.broadcast()
def submit_task(self, fn, *args, on_complete: pyqtSlot = None, **kwargs) -> Worker:
"""
Submit a task to run in a separate thread. The task will run the specified
@@ -316,8 +406,9 @@ class BECConnector:
def remove(self):
"""Cleanup the BECConnector"""
# If the widget is attached to a dock, remove it from the dock.
# TODO this should be handled by dock and dock are not by BECConnector -> issue created #473
if self._parent_dock is not None:
self._parent_dock.delete(self._name)
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
self.close()

View File

@@ -10,6 +10,8 @@ from bec_qthemes import material_icon
from qtpy import PYSIDE6
from qtpy.QtGui import QIcon
from bec_widgets.utils.bec_plugin_helper import user_widget_plugin
if PYSIDE6:
from PySide6.scripts.pyside_tool import (
_extend_path_var,
@@ -150,7 +152,12 @@ def main(): # pragma: no cover
print("PYSIDE6 is not available in the environment. Exiting...")
return
base_dir = Path(os.path.dirname(bec_widgets.__file__)).resolve()
plugin_paths = find_plugin_paths(base_dir)
if (plugin_repo := user_widget_plugin()) and isinstance(plugin_repo.__file__, str):
plugin_repo_dir = Path(os.path.dirname(plugin_repo.__file__)).resolve()
plugin_paths.extend(find_plugin_paths(plugin_repo_dir))
set_plugin_environment_variable(plugin_paths)
patch_designer()

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
@@ -17,6 +19,8 @@ logger = bec_logger.logger
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.rpc_server import RPCServer
class QtThreadSafeCallback(QObject):
cb_signal = pyqtSignal(dict, dict)
@@ -73,14 +77,23 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: RPCServer | None = None
def __new__(cls, client=None, config: str = None, *args, **kwargs):
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
*args,
**kwargs,
):
if cls._instance is None:
cls._instance = super(BECDispatcher, cls).__new__(cls)
cls._initialized = False
return cls._instance
def __init__(self, client=None, config: str | ServiceConfig = None):
def __init__(self, client=None, config: str | ServiceConfig | None = None, gui_id: str = None):
if self._initialized:
return
@@ -108,10 +121,15 @@ class BECDispatcher:
logger.warning("Could not connect to Redis, skipping start of BECClient.")
logger.success("Initialized BECDispatcher")
self.start_cli_server(gui_id=gui_id)
self._initialized = True
@classmethod
def reset_singleton(cls):
"""
Reset the singleton instance of the BECDispatcher.
"""
cls._instance = None
cls._initialized = False
@@ -178,4 +196,49 @@ class BECDispatcher:
*args: Arbitrary positional arguments
**kwargs: Arbitrary keyword arguments
"""
# pylint: disable=protected-access
self.disconnect_topics(self.client.connector._topics_cb)
def start_cli_server(self, gui_id: str | None = None):
"""
Start the CLI server.
Args:
gui_id(str, optional): The GUI ID. Defaults to None. If None, a unique identifier will be generated.
"""
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.rpc_server import RPCServer
if gui_id is None:
gui_id = self.generate_unique_identifier()
if not self.client.started:
logger.error("Cannot start CLI server without a running client")
return
self.cli_server = RPCServer(gui_id, dispatcher=self, client=self.client)
logger.success(f"Started CLI server with gui_id: {gui_id}")
def stop_cli_server(self):
"""
Stop the CLI server.
"""
if self.cli_server is None:
logger.error("Cannot stop CLI server without starting it first")
return
self.cli_server.shutdown()
self.cli_server = None
logger.success("Stopped CLI server")
@staticmethod
def generate_unique_identifier(length: int = 4) -> str:
"""
Generate a unique identifier for the application.
Args:
length: The length of the identifier. Defaults to 4.
Returns:
str: The unique identifier.
"""
allowed_chars = string.ascii_lowercase + string.digits
return "".join(random.choices(allowed_chars, k=length))

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
import importlib.metadata
import inspect
import pkgutil
from importlib import util as importlib_util
from importlib.machinery import FileFinder, ModuleSpec, SourceFileLoader
from types import ModuleType
from typing import Generator
from bec_widgets.utils.bec_widget import BECWidget
def _submodule_specs(module: ModuleType) -> tuple[ModuleSpec | None, ...]:
"""Return specs for all submodules of the given module."""
return tuple(
module_info.module_finder.find_spec(module_info.name)
for module_info in pkgutil.iter_modules(module.__path__)
if isinstance(module_info.module_finder, FileFinder)
)
def _loaded_submodules_from_specs(
submodule_specs: tuple[ModuleSpec | None, ...]
) -> Generator[ModuleType, None, None]:
"""Load all submodules from the given specs."""
for submodule in (
importlib_util.module_from_spec(spec) for spec in submodule_specs if spec is not None
):
assert isinstance(
submodule.__loader__, SourceFileLoader
), "Module found from FileFinder should have SourceFileLoader!"
submodule.__loader__.exec_module(submodule)
yield submodule
def _submodule_by_name(module: ModuleType, name: str):
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
if submod.__name__ == name:
return submod
return None
def _get_widgets_from_module(module: ModuleType) -> dict[str, "type[BECWidget]"]:
"""Find any BECWidget subclasses in the given module and return them with their names."""
from bec_widgets.utils.bec_widget import BECWidget # avoid circular import
return dict(
inspect.getmembers(
module,
predicate=lambda item: inspect.isclass(item)
and issubclass(item, BECWidget)
and item is not BECWidget,
)
)
def _all_widgets_from_all_submods(module):
"""Recursively load submodules, find any BECWidgets, and return them all as a flat dict."""
widgets = _get_widgets_from_module(module)
if not hasattr(module, "__path__"):
return widgets
for submod in _loaded_submodules_from_specs(_submodule_specs(module)):
widgets.update(_all_widgets_from_all_submods(submod))
return widgets
def user_widget_plugin() -> ModuleType | None:
plugins = importlib.metadata.entry_points(group="bec.widgets.user_widgets") # type: ignore
return None if len(plugins) == 0 else tuple(plugins)[0].load()
def get_plugin_client_module() -> ModuleType | None:
"""If there is a plugin repository installed, return the client module."""
return _submodule_by_name(plugin, "client") if (plugin := user_widget_plugin()) else None
def get_all_plugin_widgets() -> dict[str, "type[BECWidget]"]:
"""If there is a plugin repository installed, load all widgets from it."""
if plugin := user_widget_plugin():
return _all_widgets_from_all_submods(plugin)
else:
return {}
if __name__ == "__main__": # pragma: no cover
# print(get_all_plugin_widgets())
client = get_plugin_client_module()
...

View File

@@ -4,8 +4,8 @@ from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication, QWidget
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
@@ -32,8 +32,7 @@ class BECWidget(BECConnector):
config: ConnectionConfig = None,
gui_id: str | None = None,
theme_update: bool = False,
name: str | None = None,
parent_dock: BECDock | None = None,
parent_dock: BECDock | None = None, # TODO should go away -> issue created #473
parent_id: str | None = None,
**kwargs,
):
@@ -54,17 +53,17 @@ class BECWidget(BECConnector):
theme_update(bool, optional): Whether to subscribe to theme updates. Defaults to False. When set to True, the
widget's apply_theme method will be called when the theme changes.
"""
if not isinstance(self, QWidget):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
super().__init__(
client=client,
config=config,
gui_id=gui_id,
name=name,
parent_dock=parent_dock,
parent_id=parent_id,
**kwargs,
)
if not isinstance(self, QObject):
raise RuntimeError(f"{repr(self)} is not a subclass of QWidget")
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -107,6 +106,7 @@ class BECWidget(BECConnector):
"""Cleanup the widget."""
with RPCRegister.delayed_broadcast():
# All widgets need to call super().cleanup() in their cleanup method
logger.info(f"Registry cleanup for widget {self.__class__.__name__}")
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):

View File

@@ -22,10 +22,10 @@ class PaletteViewer(BECWidget, QWidget):
"""
ICON_NAME = "palette"
RPC = False
def __init__(self, *args, parent=None, **kwargs):
super().__init__(*args, theme_update=True, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, theme_update=True, **kwargs)
self.setFixedSize(400, 600)
layout = QVBoxLayout(self)
dark_mode_button = DarkModeButton(self)

View File

@@ -1,7 +1,10 @@
from __future__ import annotations
import importlib
import inspect
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.plugin_helper import _get_available_plugins
from qtpy.QtWidgets import QGraphicsWidget, QWidget
@@ -9,6 +12,9 @@ from qtpy.QtWidgets import QGraphicsWidget, QWidget
from bec_widgets.utils import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
def get_plugin_widgets() -> dict[str, BECConnector]:
"""
@@ -45,6 +51,40 @@ def _filter_plugins(obj):
return inspect.isclass(obj) and issubclass(obj, BECConnector)
def get_plugin_auto_updates() -> dict[str, type[AutoUpdates]]:
"""
Get all available auto update classes from the plugin directory. AutoUpdates must inherit from AutoUpdate and be
placed in the plugin repository's bec_widgets/auto_updates directory. The entry point for the auto updates is
specified in the respective pyproject.toml file using the following key:
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "<beamline_name>.bec_widgets.auto_updates"
e.g.
[project.entry-points."bec.widgets.auto_updates"]
plugin_widgets_update = "pxiii_bec.bec_widgets.auto_updates"
Returns:
dict[str, AutoUpdates]: A dictionary of widget names and their respective classes.
"""
modules = _get_available_plugins("bec.widgets.auto_updates")
loaded_plugins = {}
for module in modules:
mods = inspect.getmembers(module, predicate=_filter_auto_updates)
for name, mod_cls in mods:
if name in loaded_plugins:
print(f"Duplicated auto update {name}.")
loaded_plugins[name] = mod_cls
return loaded_plugins
def _filter_auto_updates(obj):
from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates
return (
inspect.isclass(obj) and issubclass(obj, AutoUpdates) and not obj.__name__ == "AutoUpdates"
)
@dataclass
class BECClassInfo:
name: str
@@ -63,6 +103,9 @@ class BECClassContainer:
def __repr__(self):
return str(list(cl.name for cl in self.collection))
def __iter__(self):
return self._collection.__iter__()
def add_class(self, class_info: BECClassInfo):
"""
Add a class to the collection.

View File

@@ -16,6 +16,7 @@ class RoundedFrame(QFrame):
parent=None,
content_widget: QWidget = None,
background_color: str = None,
orientation: str = "horizontal",
radius: int = 10,
):
QFrame.__init__(self, parent)
@@ -28,8 +29,12 @@ class RoundedFrame(QFrame):
self.setObjectName("roundedFrame")
# Create a layout for the frame
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
if orientation == "vertical":
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5)
else:
self.layout = QHBoxLayout(self)
self.layout.setContentsMargins(5, 5, 5, 5) # Set 5px margin
# Add the content widget to the layout
if content_widget:

View File

@@ -0,0 +1,262 @@
from __future__ import annotations
import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING, Callable, TypeVar
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
from redis.exceptions import RedisError
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.error_popups import ErrorPopupUtility
if TYPE_CHECKING:
from bec_lib import messages
from qtpy.QtCore import QObject
else:
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
T = TypeVar("T")
@contextmanager
def rpc_exception_hook(err_func):
"""This context replaces the popup message box for error display with a specific hook"""
# get error popup utility singleton
popup = ErrorPopupUtility()
# save current setting
old_exception_hook = popup.custom_exception_hook
# install err_func, if it is a callable
# IMPORTANT, Keep self here, because this method is overwriting the custom_exception_hook
# of the ErrorPopupUtility (popup instance) class.
def custom_exception_hook(self, exc_type, value, tb, **kwargs):
err_func({"error": popup.get_error_message(exc_type, value, tb)})
popup.custom_exception_hook = types.MethodType(custom_exception_hook, popup)
try:
yield popup
finally:
# restore state of error popup utility singleton
popup.custom_exception_hook = old_exception_hook
class RPCServer:
client: BECClient
def __init__(
self,
gui_id: str,
dispatcher: BECDispatcher | None = None,
client: BECClient | None = None,
config=None,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_callback(self.broadcast_registry_update)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
)
# Setup QTimer for heartbeat
self._heartbeat_timer = QTimer()
self._heartbeat_timer.timeout.connect(self.emit_heartbeat)
self._heartbeat_timer.start(200)
self._registry_update_callbacks = []
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
if request_id is None:
logger.error("Received RPC instruction without request_id")
return
logger.debug(f"Received RPC instruction: {msg}, metadata: {metadata}")
with rpc_exception_hook(functools.partial(self.send_response, request_id, False)):
try:
obj = self.get_object_from_config(msg["parameter"])
method = msg["action"]
args = msg["parameter"].get("args", [])
kwargs = msg["parameter"].get("kwargs", {})
res = self.run_rpc(obj, method, args, kwargs)
except Exception:
content = traceback.format_exc()
logger.error(f"Error while executing RPC instruction: {content}")
self.send_response(request_id, False, {"error": content})
else:
logger.debug(f"RPC instruction executed successfully: {res}")
self.send_response(request_id, True, {"result": res})
def send_response(self, request_id: str, accepted: bool, msg: dict):
self.client.connector.set_and_publish(
MessageEndpoints.gui_instruction_response(request_id),
messages.RequestResponseMessage(accepted=accepted, message=msg),
expire=60,
)
def get_object_from_config(self, config: dict):
gui_id = config.get("gui_id")
obj = self.rpc_register.get_rpc_by_id(gui_id)
if obj is None:
raise ValueError(f"Object with gui_id {gui_id} not found")
return obj
def run_rpc(self, obj, method, args, kwargs):
# Run with rpc registry broadcast, but only once
with RPCRegister.delayed_broadcast():
logger.debug(f"Running RPC instruction: {method} with args: {args}, kwargs: {kwargs}")
method_obj = getattr(obj, method)
# check if the method accepts args and kwargs
if not callable(method_obj):
if not args:
res = method_obj
else:
setattr(obj, method, args[0])
res = None
else:
res = method_obj(*args, **kwargs)
if isinstance(res, list):
res = [self.serialize_object(obj) for obj in res]
elif isinstance(res, dict):
res = {key: self.serialize_object(val) for key, val in res.items()}
else:
res = self.serialize_object(res)
return res
def serialize_object(self, obj: T) -> None | dict | T:
"""
Serialize all BECConnector objects.
Args:
obj: The object to be serialized.
Returns:
None | dict | T: The serialized object or None if the object is not a BECConnector.
"""
if not isinstance(obj, BECConnector):
return obj
# Respect RPC = False
if getattr(obj, "RPC", True) is False:
return None
return self._serialize_bec_connector(obj, wait=True)
def emit_heartbeat(self) -> None:
"""
Emit a heartbeat message to the GUI server.
This method is called periodically to indicate that the server is still running.
"""
logger.trace(f"Emitting heartbeat for {self.gui_id}")
try:
self.client.connector.set(
MessageEndpoints.gui_heartbeat(self.gui_id),
messages.StatusMessage(name=self.gui_id, status=self.status, info={}),
expire=10,
)
except RedisError as exc:
logger.error(f"Error while emitting heartbeat: {exc}")
def broadcast_registry_update(self, connections: dict) -> None:
"""
Broadcast the registry update to all the callbacks.
This method is called whenever the registry is updated.
"""
data = {}
for key, val in connections.items():
if not isinstance(val, BECConnector):
continue
if not getattr(val, "RPC", True):
continue
data[key] = self._serialize_bec_connector(val)
logger.info(f"Broadcasting registry update: {data} for {self.gui_id}")
self.client.connector.xadd(
MessageEndpoints.gui_registry_state(self.gui_id),
msg_dict={"data": messages.GUIRegistryStateMessage(state=data)},
max_size=1,
)
def _serialize_bec_connector(self, connector: BECConnector, wait=False) -> dict:
"""
Create the serialization dict for a single BECConnector.
Args:
connector (BECConnector): The BECConnector to serialize.
wait (bool): If True, wait until the object is registered in the RPC register.
Returns:
dict: The serialized BECConnector object.
"""
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
if wait:
while not self.rpc_register.object_is_registered(connector):
QApplication.processEvents()
widget_class = getattr(connector, "rpc_widget_class", None)
if not widget_class:
widget_class = connector.__class__.__name__
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": widget_class,
"config": config_dict,
"__rpc__": True,
}
@staticmethod
def _get_becwidget_ancestor(widget: QObject) -> BECConnector | None:
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
# Suppose clients register callbacks to receive updates
def add_registry_update_callback(self, cb: Callable) -> None:
"""
Add a callback to be called whenever the registry is updated.
The specified callback is called whenever the registry is updated.
Args:
cb (Callable): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self._registry_update_callbacks.append(cb)
def shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
logger.info("Succeded in shutting down CLI server")
self.client.shutdown()

View File

@@ -1,7 +1,11 @@
from bec_lib.logger import bec_logger
from PySide6.QtGui import QCloseEvent
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from bec_widgets.utils.error_popups import SafeSlot
logger = bec_logger.logger
class SettingWidget(QWidget):
"""
@@ -37,6 +41,15 @@ class SettingWidget(QWidget):
"""
pass
def cleanup(self):
"""
Cleanup the settings widget.
"""
def closeEvent(self, event: QCloseEvent) -> None:
self.cleanup()
return super().closeEvent(event)
class SettingsDialog(QDialog):
"""
@@ -99,8 +112,17 @@ class SettingsDialog(QDialog):
Accept the changes made in the settings widget and close the dialog.
"""
self.widget.accept_changes()
self.cleanup()
super().accept()
@SafeSlot()
def reject(self):
"""
Reject the changes made in the settings widget and close the dialog.
"""
self.cleanup()
super().reject()
@SafeSlot()
def apply_changes(self):
"""
@@ -114,7 +136,10 @@ class SettingsDialog(QDialog):
"""
self.button_box.close()
self.button_box.deleteLater()
self.widget.close()
self.widget.deleteLater()
def closeEvent(self, event):
logger.info("Closing settings dialog")
self.cleanup()
super().closeEvent(event)

View File

@@ -35,7 +35,6 @@ class SidePanel(QWidget):
super().__init__(parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("SidePanel")
self._orientation = orientation
self._panel_max_width = panel_max_width

View File

@@ -858,7 +858,7 @@ class MainWindow(QMainWindow): # pragma: no cover
# For theme testing
self.dark_button = DarkModeButton(toolbar=True)
self.dark_button = DarkModeButton(parent=self, toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)

View File

@@ -1,16 +1,17 @@
import os
from qtpy import PYQT6, PYSIDE6, QT_VERSION
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6
from qtpy.QtCore import QFile, QIODevice
from bec_widgets.utils.generate_designer_plugin import DesignerPluginInfo
from bec_widgets.utils.plugin_utils import get_custom_classes
logger = bec_logger.logger
if PYSIDE6:
from PySide6.QtUiTools import QUiLoader
class CustomUiLoader(QUiLoader):
def __init__(self, baseinstance, custom_widgets: dict = None):
def __init__(self, baseinstance, custom_widgets: dict | None = None):
super().__init__(baseinstance)
self.custom_widgets = custom_widgets or {}
@@ -18,10 +19,9 @@ if PYSIDE6:
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
widget = self.custom_widgets[class_name](self.baseinstance)
return widget
return super().createWidget(class_name, parent, name)
return super().createWidget(class_name, self.baseinstance, name)
class UILoader:
@@ -51,7 +51,7 @@ class UILoader:
Returns:
QWidget: The loaded widget.
"""
parent = parent or self.parent
loader = CustomUiLoader(parent, self.custom_widgets)
file = QFile(ui_file)
if not file.open(QIODevice.ReadOnly):

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import shiboken6 as shb
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
@@ -275,39 +276,162 @@ class WidgetHierarchy:
grab_values: bool = False,
prefix: str = "",
exclude_internal_widgets: bool = True,
only_bec_widgets: bool = False,
show_parent: bool = True,
) -> None:
"""
Print the widget hierarchy to the console.
Args:
widget: Widget to print the hierarchy of
widget: Widget to print the hierarchy of.
indent(int, optional): Level of indentation.
grab_values(bool,optional): Whether to grab the values of the widgets.
prefix(stc,optional): Custom string prefix for indentation.
prefix(str,optional): Custom string prefix for indentation.
exclude_internal_widgets(bool,optional): Whether to exclude internal widgets (e.g. QComboBox in PyQt6).
only_bec_widgets(bool, optional): Whether to print only widgets that are instances of BECWidget.
show_parent(bool, optional): Whether to display which BECWidget is the parent of each discovered BECWidget.
"""
widget_info = f"{widget.__class__.__name__} ({widget.objectName()})"
if grab_values:
value = WidgetIO.get_value(widget, ignore_errors=True)
value_str = f" [value: {value}]" if value is not None else ""
widget_info += value_str
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
# 1) Filter out widgets that are not BECConnectors (if 'only_bec_widgets' is True)
is_bec = isinstance(widget, BECConnector)
if only_bec_widgets and not is_bec:
return
# 2) Determine and print the parent's info (closest BECConnector)
parent_info = ""
if show_parent and is_bec:
ancestor = WidgetHierarchy._get_becwidget_ancestor(widget)
if ancestor:
parent_label = ancestor.objectName() or ancestor.__class__.__name__
parent_info = f" parent={parent_label}"
else:
parent_info = " parent=None"
widget_info = f"{widget.__class__.__name__} ({widget.objectName()}){parent_info}"
print(prefix + widget_info)
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
# 3) If it's a Waveform, explicitly print the curves
if isinstance(widget, Waveform):
for curve in widget.curves:
curve_prefix = prefix + " └─ "
print(
f"{curve_prefix}{curve.__class__.__name__} ({curve.objectName()}) "
f"parent={widget.objectName()}"
)
# 4) Recursively handle each child if:
# - It's a QWidget
# - It is a BECConnector (or we don't care about filtering)
# - Its closest BECConnector parent is the current widget
for child in widget.findChildren(QWidget):
if only_bec_widgets and not isinstance(child, BECConnector):
continue
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
WidgetHierarchy.print_widget_hierarchy(
child, indent + 1, grab_values, prefix=child_prefix + arrow
child,
indent=indent + 1,
grab_values=grab_values,
prefix=child_prefix,
exclude_internal_widgets=exclude_internal_widgets,
only_bec_widgets=only_bec_widgets,
show_parent=show_parent,
)
@staticmethod
def print_becconnector_hierarchy_from_app():
"""
Enumerate ALL BECConnector objects in the QApplication.
Also detect if a widget is a PlotBase, and add any data items
(PlotDataItem-like) that are also BECConnector objects.
Build a parent->children graph where each child's 'parent'
is its closest BECConnector ancestor. Print the entire hierarchy
from the root(s).
The result is a single, consolidated tree for your entire
running GUI, including PlotBase data items that are BECConnector.
"""
import sys
from collections import defaultdict
from qtpy.QtWidgets import QApplication
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.plot_base import PlotBase
# 1) Gather ALL QWidget-based BECConnector objects
all_qwidgets = QApplication.allWidgets()
bec_widgets = set(w for w in all_qwidgets if isinstance(w, BECConnector))
# 2) Also gather any BECConnector-based data items from PlotBase widgets
for w in all_qwidgets:
if isinstance(w, PlotBase) and hasattr(w, "plot_item"):
plot_item = w.plot_item
if hasattr(plot_item, "listDataItems"):
for data_item in plot_item.listDataItems():
if isinstance(data_item, BECConnector):
bec_widgets.add(data_item)
# 3) Build a map of (closest BECConnector parent) -> list of children
parent_map = defaultdict(list)
for w in bec_widgets:
parent_bec = WidgetHierarchy._get_becwidget_ancestor(w)
parent_map[parent_bec].append(w)
# 4) Define a recursive printer to show each object's children
def print_tree(parent, prefix=""):
children = parent_map[parent]
for i, child in enumerate(children):
connector_class = child.__class__.__name__
connector_name = child.objectName() or connector_class
if parent is None:
parent_label = "None"
else:
parent_label = parent.objectName() or parent.__class__.__name__
line = f"{connector_class} ({connector_name}) parent={parent_label}"
# Determine tree-branch symbols
is_last = i == len(children) - 1
branch_str = "└─ " if is_last else "├─ "
print(prefix + branch_str + line)
# Recurse deeper
next_prefix = prefix + (" " if is_last else "")
print_tree(child, prefix=next_prefix)
# 5) Print top-level items (roots) whose BECConnector parent is None
roots = parent_map[None]
for r_i, root in enumerate(roots):
root_class = root.__class__.__name__
root_name = root.objectName() or root_class
line = f"{root_class} ({root_name}) parent=None"
is_last_root = r_i == len(roots) - 1
print(line)
# Recurse into its children
print_tree(root, prefix=" ")
@staticmethod
def _get_becwidget_ancestor(widget):
"""
Traverse up the parent chain to find the nearest BECConnector.
Returns None if none is found.
"""
from bec_widgets.utils import BECConnector
if not shb.isValid(widget):
return None
parent = widget.parent()
while parent is not None:
if isinstance(parent, BECConnector):
return parent
parent = parent.parent()
return None
@staticmethod
def export_config_to_dict(
widget: QWidget,

View File

@@ -0,0 +1,364 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class AutoUpdates(BECMainWindow):
_default_dock: BECDock
USER_ACCESS = ["enabled", "enabled.setter", "selected_device", "selected_device.setter"]
RPC = True
# enforce that subclasses have the same rpc widget class
rpc_widget_class = "AutoUpdates"
def __init__(
self, parent=None, gui_id: str = None, window_title="Auto Update", *args, **kwargs
):
super().__init__(parent=parent, gui_id=gui_id, window_title=window_title, **kwargs)
self.dock_area = BECDockArea(parent=self, object_name="dock_area")
self.setCentralWidget(self.dock_area)
self._auto_update_selected_device: str | None = None
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = True
self.start_auto_update()
def start_auto_update(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def stop_auto_update(self):
"""
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@property
def selected_device(self) -> str | None:
"""
Get the selected device from the auto update config.
Returns:
str: The selected device. If no device is selected, None is returned.
"""
return self._auto_update_selected_device
@selected_device.setter
def selected_device(self, value: str | None) -> None:
"""
Set the selected device in the auto update config.
Args:
value(str): The selected device.
"""
self._auto_update_selected_device = value
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
Callback for scan status messages.
"""
msg = ScanStatusMessage(**content, metadata=metadata)
if not self.enabled:
return
self.enable_gui_highlights(True)
match msg.status:
case "open":
self.on_scan_open(msg)
case "closed":
self.on_scan_closed(msg)
case ["aborted", "halted"]:
self.on_scan_abort(msg)
case _:
pass
def start_default_dock(self):
"""
Create a default dock for the auto updates.
"""
self.dock_name = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
@overload
def set_dock_to_widget(self, widget: Literal["Waveform"]) -> Waveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["Image"]) -> Image: ...
@overload
def set_dock_to_widget(self, widget: Literal["ScatterWaveform"]) -> ScatterWaveform: ...
@overload
def set_dock_to_widget(self, widget: Literal["MotorMap"]) -> MotorMap: ...
@overload
def set_dock_to_widget(self, widget: Literal["MultiWaveform"]) -> MultiWaveform: ...
def set_dock_to_widget(
self,
widget: Literal["Waveform", "Image", "ScatterWaveform", "MotorMap", "MultiWaveForm"] | str,
) -> BECWidget:
"""
Set the dock to the widget.
Args:
widget (str): The widget to set the dock to. Must be the name of a valid widget class.
Returns:
BECWidget: The widget that was set.
"""
if self._default_dock is None or self.current_widget is None:
logger.warning(
f"Auto Updates: No default dock found. Creating a new one with name {self.dock_name}"
)
self.start_default_dock()
assert self.current_widget is not None
if not self.current_widget.__class__.__name__ == widget:
self._default_dock.delete(self.current_widget.object_name)
self.current_widget = self._default_dock.new(widget)
return self.current_widget
def get_selected_device(
self, monitored_devices, selected_device: str | None = None
) -> str | None:
"""
Get the selected device for the plot. If no device is selected, the first
device in the monitored devices list is selected.
"""
if selected_device is None:
selected_device = self.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
sel_device = monitored_devices[0]
return sel_device
return None
def enable_gui_highlights(self, enable: bool) -> None:
"""
Enable or disable GUI highlights.
Args:
enable (bool): Whether to enable or disable the highlights.
"""
if enable:
title = self.dock_area.window().windowTitle()
if " [Auto Updates]" in title:
return
self.dock_area.window().setWindowTitle(f"{title} [Auto Updates]")
else:
title = self.dock_area.window().windowTitle()
self.dock_area.window().setWindowTitle(title.replace(" [Auto Updates]", ""))
@property
def enabled(self) -> bool:
"""
Get the enabled status of the auto updates.
"""
return self._enabled
@enabled.setter
def enabled(self, value: bool) -> None:
"""
Set the enabled status of the auto updates.
"""
if self._enabled == value:
return
self._enabled = value
if value:
self.start_auto_update()
self.enable_gui_highlights(True)
self.on_start()
else:
self.stop_auto_update()
self.enable_gui_highlights(False)
self.on_stop()
def cleanup(self) -> None:
"""
Cleanup procedure to run when the auto updates are disabled.
"""
self.enabled = False
self.stop_auto_update()
self.dock_area.close()
self.dock_area.deleteLater()
self.dock_area = None
super().cleanup()
########################################################################
################# Update Functions #####################################
########################################################################
def simple_line_scan(self, info: ScanStatusMessage) -> None:
"""
Simple line scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Get the scan report devices reported by the scan
dev_x = info.scan_report_devices[0] # type:ignore
# For the y axis, get the selected device
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Clear the waveform widget and plot the data
# with the scan number and device names
# as the label and title
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(
f"Auto Update [simple_line_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}"
)
def simple_grid_scan(self, info: ScanStatusMessage) -> None:
"""
Simple grid scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# Set the dock to the scatter waveform widget
scatter = self.set_dock_to_widget("ScatterWaveform")
# Get the scan report devices reported by the scan
dev_x, dev_y = info.scan_report_devices[0], info.scan_report_devices[1] # type:ignore
dev_z = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if None in (dev_x, dev_y, dev_z):
return
# Clear the scatter waveform widget and plot the data
scatter.clear_all()
scatter.plot(
x_name=dev_x, y_name=dev_y, z_name=dev_z, label=f"Scan {info.scan_number} - {dev_z}"
)
logger.info(
f"Auto Update [simple_grid_scan]: Started plot with: x_name={dev_x}, y_name={dev_y}, z_name={dev_z}"
)
def best_effort(self, info: ScanStatusMessage) -> None:
"""
Best effort scan.
Args:
info (ScanStatusMessage): The scan status message.
"""
# If the scan report devices are empty, there is nothing we can do
if not info.scan_report_devices:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
if not dev_y:
return
# Set the dock to the waveform widget
wf = self.set_dock_to_widget("Waveform")
# Clear the waveform widget and plot the data
wf.clear_all()
wf.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
title=f"Scan {info.scan_number}",
x_label=dev_x,
y_label=dev_y,
)
logger.info(f"Auto Update [best_effort]: Started plot with: x_name={dev_x}, y_name={dev_y}")
#######################################################################
################# GUI Callbacks #######################################
#######################################################################
def on_start(self) -> None:
"""
Procedure to run when the auto updates are enabled.
"""
self.start_default_dock()
def on_stop(self) -> None:
"""
Procedure to run when the auto updates are disabled.
"""
def on_scan_open(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan starts.
Args:
msg (ScanStatusMessage): The scan status message.
"""
if msg.scan_name == "line_scan" and msg.scan_report_devices:
return self.simple_line_scan(msg)
if msg.scan_name == "grid_scan" and msg.scan_report_devices:
return self.simple_grid_scan(msg)
if msg.scan_report_devices:
return self.best_effort(msg)
return None
def on_scan_closed(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan ends.
Args:
msg (ScanStatusMessage): The scan status message.
"""
def on_scan_abort(self, msg: ScanStatusMessage) -> None:
"""
Procedure to run when a scan is aborted.
Args:
msg (ScanStatusMessage): The scan status message.
"""

View File

@@ -133,6 +133,7 @@ class BECDock(BECWidget, Dock):
parent_id: str | None = None,
config: DockConfig | None = None,
name: str | None = None,
object_name: str | None = None,
client=None,
gui_id: str | None = None,
closable: bool = True,
@@ -148,12 +149,17 @@ class BECDock(BECWidget, Dock):
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(
client=client, config=config, gui_id=gui_id, name=name, parent_id=parent_id
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
self.parent_dock_area = parent_dock_area
# Layout Manager
@@ -193,7 +199,7 @@ class BECDock(BECWidget, Dock):
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget._name, widget) for widget in self.element_list)
return dict((widget.object_name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
@@ -265,8 +271,9 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def _get_list_of_widget_name_of_parent_dock_area(self):
docks = self.parent_dock_area.panel_list
def _get_list_of_widget_name_of_parent_dock_area(self) -> list[str]:
if (docks := self.parent_dock_area.panel_list) is None:
return []
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
@@ -295,27 +302,11 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
@@ -325,16 +316,20 @@ class BECDock(BECWidget, Dock):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget, name=name, parent_dock=self, parent_id=self.gui_id
widget_type=widget,
object_name=name,
parent_dock=self,
parent_id=self.gui_id,
parent=self,
),
)
else:
widget._name = name # pylint: disable=protected-access
widget.object_name = name
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if hasattr(widget, "config"):
widget.config.gui_id = widget.gui_id
self.config.widgets[widget._name] = widget.config # pylint: disable=protected-access
self.config.widgets[widget.object_name] = widget.config
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
@@ -364,7 +359,7 @@ class BECDock(BECWidget, Dock):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self._name)
self.parent_dock_area.delete(self.object_name)
def delete(self, widget_name: str) -> None:
"""
@@ -374,7 +369,7 @@ class BECDock(BECWidget, Dock):
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget._name == widget_name]
widgets = [widget for widget in self.widgets if widget.object_name == widget_name]
if len(widgets) == 0:
logger.warning(
f"Widget with name {widget_name} not found in dock {self.name()}. "
@@ -390,7 +385,7 @@ class BECDock(BECWidget, Dock):
else:
widget = widgets[0]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget._name, None)
self.config.widgets.pop(widget.object_name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
@@ -400,7 +395,7 @@ class BECDock(BECWidget, Dock):
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget._name) # pylint: disable=protected-access
self.delete(widget.object_name)
def cleanup(self):
"""

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
from typing import Literal, Optional
from weakref import WeakValueDictionary
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea.DockArea import DockArea
@@ -21,7 +20,9 @@ from bec_widgets.utils.toolbar import (
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
@@ -47,6 +48,10 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
"""
Container for other widgets. Widgets can be added to the dock area and arranged in a grid layout.
"""
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
@@ -62,7 +67,6 @@ class BECDockArea(BECWidget, QWidget):
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"restore_state",
]
@@ -73,7 +77,7 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
name: str | None = None,
object_name: str = None,
**kwargs,
) -> None:
if config is None:
@@ -82,17 +86,25 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent)
self._parent = parent
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self._instructions_visible = True
self.dark_mode_button = DarkModeButton(parent=self, parent_id=self.gui_id, toolbar=True)
self.dock_area = DockArea()
self.toolbar = ModularToolBar(
parent=self,
actions={
"menu_plots": ExpandableMenuAction(
label="Add Plot ",
@@ -172,7 +184,7 @@ class BECDockArea(BECWidget, QWidget):
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self.toolbar.addWidget(self.dark_mode_button)
self._hook_toolbar()
def minimumSizeHint(self):
@@ -243,17 +255,6 @@ class BECDockArea(BECWidget, QWidget):
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
def selected_device(self) -> str:
gui_id = QApplication.instance().gui_id
auto_update_config = self.client.connector.get(
MessageEndpoints.gui_auto_update_config(gui_id)
)
try:
return auto_update_config.selected_device
except AttributeError:
return None
@property
def panels(self) -> dict[str, BECDock]:
"""
@@ -352,17 +353,26 @@ class BECDockArea(BECWidget, QWidget):
Returns:
BECDock: The created dock.
"""
dock_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
dock_names = [
dock.object_name for dock in self.panel_list
] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self._name} and id {self.gui_id}."
f"with name: {self.object_name} and id {self.gui_id}."
)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(name=name, parent_dock_area=self, parent_id=self.gui_id, closable=closable)
dock = BECDock(
parent=self,
name=name, # this is dock name pyqtgraph property, this is displayed on label
object_name=name, # this is a real qt object name passed to BECConnector
parent_dock_area=self,
parent_id=self.gui_id,
closable=closable,
)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
@@ -432,6 +442,8 @@ class BECDockArea(BECWidget, QWidget):
self.delete_all()
self.toolbar.close()
self.toolbar.deleteLater()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
self.dock_area.close()
self.dock_area.deleteLater()
super().cleanup()
@@ -482,7 +494,18 @@ class BECDockArea(BECWidget, QWidget):
# self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
"""
Remove the dock area. If the dock area is embedded in a BECMainWindow and
is set as the central widget, the main window will be closed.
"""
parent = self.parent()
if isinstance(parent, BECMainWindow):
central_widget = parent.centralWidget()
if central_widget is self:
# Closing the parent will also close the dock area
parent.close()
return
self.close()
@@ -495,11 +518,13 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_1 = dock_area.new(name="dock_0", widget="DarkModeButton")
dock_1.new(widget="DarkModeButton")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="Waveform")
dock_area.new(widget="DarkModeButton")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
WidgetHierarchy.print_becconnector_hierarchy_from_app()
app.exec_()
sys.exit(app.exec_())

View File

@@ -34,7 +34,6 @@ class LayoutManagerWidget(QWidget):
def __init__(self, parent=None, auto_reindex=True):
super().__init__(parent)
self.setObjectName("LayoutManagerWidget")
self.layout = QGridLayout(self)
self.auto_reindex = auto_reindex

View File

@@ -0,0 +1,15 @@
import webbrowser
class BECWebLinksMixin:
@staticmethod
def open_bec_docs():
webbrowser.open("https://beamline-experiment-control.readthedocs.io/en/latest/")
@staticmethod
def open_bec_widgets_docs():
webbrowser.open("https://bec.readthedocs.io/projects/bec-widgets/en/latest/")
@staticmethod
def open_bec_bug_report():
webbrowser.open("https://gitlab.psi.ch/groups/bec/-/issues/")

View File

@@ -1,74 +1,189 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QMainWindow
import os
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from qtpy.QtCore import QSize
from qtpy.QtGui import QAction, QActionGroup, QIcon
from qtpy.QtWidgets import QApplication, QMainWindow, QStyle
import bec_widgets
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECMainWindow(BECWidget, QMainWindow):
def __init__(self, gui_id: str = None, *args, **kwargs):
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
RPC = False
PLUGIN = False
def _dump(self):
"""Return a dictionary with informations about the application state, for use in tests"""
# TODO: ModularToolBar and something else leak top-level widgets (3 or 4 QMenu + 2 QWidget);
# so, a filtering based on title is applied here, but the solution is to not have those widgets
# as top-level (so for now, a window with no title does not appear in _dump() result)
def __init__(
self,
parent=None,
gui_id: str = None,
client=None,
window_title: str = "BEC",
*args,
**kwargs,
):
super().__init__(parent=parent, gui_id=gui_id, **kwargs)
# NOTE: the main window itself is excluded, since we want to dump dock areas
info = {
tlw.gui_id: {
"title": tlw.windowTitle(),
"visible": tlw.isVisible(),
"class": str(type(tlw)),
}
for tlw in QApplication.instance().topLevelWidgets()
if tlw is not self and tlw.windowTitle()
}
# Add the main window dock area
info[self.centralWidget().gui_id] = {
"title": self.windowTitle(),
"visible": self.isVisible(),
"class": str(type(self.centralWidget())),
}
return info
self.app = QApplication.instance()
self.setWindowTitle(window_title)
self._init_ui()
self._connect_to_theme_change()
def new_dock_area(
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
) -> BECDockArea:
"""Create a new dock area.
def _init_ui(self):
Args:
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
BECDockArea: The newly created dock area.
# Set the icon
self._init_bec_icon()
# Set Menu and Status bar
self._setup_menu_bar()
# BEC Specific UI
self.display_app_id()
def _init_bec_icon(self):
icon = self.app.windowIcon()
if icon.isNull():
print("No icon is set, setting default icon")
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.app.setWindowIcon(icon)
else:
print("An icon is set")
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
def display_app_id(self):
"""
with RPCRegister.delayed_broadcast() as rpc_register:
existing_dock_areas = rpc_register.get_names_of_rpc_by_class_type(BECDockArea)
if name is not None:
if name in existing_dock_areas:
raise ValueError(
f"Name {name} must be unique for dock areas, but already exists: {existing_dock_areas}."
)
else:
name = "dock_area"
name = WidgetContainerUtils.generate_unique_name(name, existing_dock_areas)
dock_area = BECDockArea(name=name)
dock_area.resize(dock_area.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
dock_area.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
dock_area.setGeometry(*geometry)
dock_area.show()
return dock_area
Display the app ID in the status bar.
"""
if self.bec_dispatcher.cli_server is None:
status_message = "Not connected"
else:
# Get the server ID from the dispatcher
server_id = self.bec_dispatcher.cli_server.gui_id
status_message = f"App ID: {server_id}"
self.statusBar().showMessage(status_message)
def _fetch_theme(self) -> str:
return self.app.theme.theme
def _get_launcher_from_qapp(self):
"""
Get the launcher from the QApplication instance.
"""
from bec_widgets.applications.launch_window import LaunchWindow
qapp = QApplication.instance()
widgets = qapp.topLevelWidgets()
widgets = [w for w in widgets if isinstance(w, LaunchWindow)]
if widgets:
return widgets[0]
return None
def _show_launcher(self):
"""
Show the launcher if it exists.
"""
launcher = self._get_launcher_from_qapp()
if launcher:
launcher.show()
launcher.activateWindow()
launcher.raise_()
def _setup_menu_bar(self):
"""
Setup the menu bar for the main window.
"""
menu_bar = self.menuBar()
##########################################
# Launch menu
launch_menu = menu_bar.addMenu("New")
open_launcher_action = QAction("Open Launcher", self)
launch_menu.addAction(open_launcher_action)
open_launcher_action.triggered.connect(self._show_launcher)
########################################
# Theme menu
theme_menu = menu_bar.addMenu("Theme")
theme_group = QActionGroup(self)
light_theme_action = QAction("Light Theme", self, checkable=True)
dark_theme_action = QAction("Dark Theme", self, checkable=True)
theme_group.addAction(light_theme_action)
theme_group.addAction(dark_theme_action)
theme_group.setExclusive(True)
theme_menu.addAction(light_theme_action)
theme_menu.addAction(dark_theme_action)
# Connect theme actions
light_theme_action.triggered.connect(lambda: self.change_theme("light"))
dark_theme_action.triggered.connect(lambda: self.change_theme("dark"))
# Set the default theme
theme = self.app.theme.theme
if theme == "light":
light_theme_action.setChecked(True)
elif theme == "dark":
dark_theme_action.setChecked(True)
########################################
# Help menu
help_menu = menu_bar.addMenu("Help")
help_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxQuestion)
bug_icon = QApplication.style().standardIcon(QStyle.SP_MessageBoxInformation)
bec_docs = QAction("BEC Docs", self)
bec_docs.setIcon(help_icon)
widgets_docs = QAction("BEC Widgets Docs", self)
widgets_docs.setIcon(help_icon)
bug_report = QAction("Bug Report", self)
bug_report.setIcon(bug_icon)
bec_docs.triggered.connect(BECWebLinksMixin.open_bec_docs)
widgets_docs.triggered.connect(BECWebLinksMixin.open_bec_widgets_docs)
bug_report.triggered.connect(BECWebLinksMixin.open_bec_bug_report)
help_menu.addAction(bec_docs)
help_menu.addAction(widgets_docs)
help_menu.addAction(bug_report)
@SafeSlot(str)
def change_theme(self, theme: str):
apply_theme(theme)
def cleanup(self):
super().close()
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
if not isinstance(central_widget, BECWidget):
# if the central widget is not a BECWidget, we need to call the cleanup method
# of all widgets whose parent is the current BECMainWindow
children = self.findChildren(BECWidget)
for child in children:
ancestor = WidgetHierarchy._get_becwidget_ancestor(child)
if ancestor is self:
child.cleanup()
child.close()
child.deleteLater()
super().cleanup()
class UILaunchWindow(BECMainWindow):
RPC = True

View File

@@ -11,6 +11,7 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = False
def __init__(
self,
@@ -22,9 +23,7 @@ class AbortButton(BECWidget, QWidget):
scan_id=None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -11,11 +11,10 @@ class ResetButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "restart_alt"
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -11,10 +11,10 @@ class ResumeButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "resume"
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()

View File

@@ -11,10 +11,10 @@ class StopButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "dangerous"
RPC = False
def __init__(self, parent=None, client=None, config=None, gui_id=None, toolbar=False, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()

View File

@@ -8,13 +8,17 @@ from bec_widgets.utils.colors import get_accent_colors, get_theme_palette
class PositionIndicator(BECWidget, QWidget):
"""
Display a position within a defined range, e.g. motor limits.
"""
USER_ACCESS = ["set_value", "set_range", "vertical", "indicator_width", "rounded_corners"]
PLUGIN = True
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.position = 50
self.min_value = 0
self.max_value = 100

View File

@@ -40,6 +40,7 @@ class DeviceUpdateUIComponents(TypedDict):
stop: QPushButton
tweak_increase: QPushButton
tweak_decrease: QPushButton
units: QLabel
class PositionerBoxBase(BECWidget, CompactPopupWidget):
@@ -47,6 +48,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
current_path = ""
ICON_NAME = "switch_right"
RPC = False
def __init__(self, parent=None, **kwargs):
"""Initialize the PositionerBox widget.
@@ -55,8 +57,7 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
self._dialog = None
self.get_bec_shortcuts()
@@ -84,16 +85,33 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
limit_update: Callable[[tuple[float, float]], None],
):
"""Init the device view and readback"""
if self._check_device_is_valid(device):
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
if not self._check_device_is_valid(device):
return
data = self.dev[device].read()
self._on_device_readback(
device,
self._device_ui_components(device),
{"signals": data},
{},
position_emit,
limit_update,
)
ui = self._device_ui_components(device)
if not ui.get("units"):
return
try:
egu = f"[{self.dev[device].egu()}]"
except Exception:
egu = ""
if egu:
ui["units"].setVisible(True)
ui["units"].setText(egu)
else:
ui["units"].setVisible(False)
def _stop_device(self, device: str):
"""Stop call"""

View File

@@ -170,6 +170,7 @@ class PositionerBox(PositionerBoxBase):
"stop": self.ui.stop,
"tweak_increase": self.ui.tweak_right,
"tweak_decrease": self.ui.tweak_left,
"units": self.ui.units,
}
@SafeSlot(dict, dict)

View File

@@ -135,6 +135,29 @@
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units">
<property name="toolTip">
<string>Motor units</string>
</property>
<property name="text">
<string></string>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer">
<property name="orientation">
@@ -203,16 +226,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@@ -1,4 +1,4 @@
""" Module for a PositionerBox2D widget to control two positioner devices."""
"""Module for a PositionerBox2D widget to control two positioner devices."""
from __future__ import annotations
@@ -312,6 +312,7 @@ class PositionerBox2D(PositionerBoxBase):
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_hor,
"tweak_decrease": self.ui.tweak_decrease_hor,
"units": self.ui.units_hor,
}
elif device == "vertical":
return {
@@ -324,6 +325,7 @@ class PositionerBox2D(PositionerBoxBase):
"stop": self.ui.stop_button,
"tweak_increase": self.ui.tweak_increase_ver,
"tweak_decrease": self.ui.tweak_decrease_ver,
"units": self.ui.units_ver,
}
else:
raise ValueError(f"Device {device} is not represented by this UI")

View File

@@ -6,8 +6,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>326</width>
<height>323</height>
<width>402</width>
<height>394</height>
</rect>
</property>
<property name="windowTitle">
@@ -23,7 +23,7 @@
<property name="title">
<string>No positioner selected</string>
</property>
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0">
<layout class="QGridLayout" name="gridLayout_6" rowstretch="0,0,0,0,0,0,0">
<property name="topMargin">
<number>0</number>
</property>
@@ -40,15 +40,38 @@
</widget>
</item>
<item>
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
<spacer name="horizontalSpacer_18">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units_ver">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_19">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_ver">
<property name="minimumSize">
@@ -67,20 +90,7 @@
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="5" column="0">
<item row="6" column="0">
<layout class="QHBoxLayout" name="horizontalLayout_6">
<item>
<widget class="QDoubleSpinBox" name="step_size_ver">
@@ -94,6 +104,29 @@
</item>
</layout>
</item>
<item row="3" column="0">
<widget class="QLineEdit" name="setpoint_ver">
<property name="enabled">
<bool>false</bool>
</property>
<property name="focusPolicy">
<enum>Qt::FocusPolicy::StrongFocus</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="readback_ver">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -132,15 +165,38 @@
</widget>
</item>
<item>
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
<spacer name="horizontalSpacer_9">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="units_hor">
<property name="text">
<string/>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_13">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="SpinnerWidget" name="spinner_widget_hor">
<property name="minimumSize">
@@ -173,6 +229,16 @@
</item>
</layout>
</item>
<item row="2" column="0">
<widget class="QLabel" name="readback_hor">
<property name="text">
<string>Position</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
@@ -525,16 +591,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>StopButton</class>
<extends>QWidget</extends>
<header>stop_button</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>

View File

@@ -116,6 +116,13 @@
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="units">
<property name="text">
<string>TextLabel</string>
</property>
</widget>
</item>
</layout>
</item>
<item>
@@ -218,16 +225,16 @@
</layout>
</widget>
<customwidgets>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
<customwidget>
<class>PositionIndicator</class>
<extends>QWidget</extends>
<header>position_indicator</header>
</customwidget>
<customwidget>
<class>SpinnerWidget</class>
<extends>QWidget</extends>
<header>spinner_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>

View File

@@ -69,8 +69,7 @@ class PositionerGroup(BECWidget, QWidget):
Args:
parent: The parent widget.
"""
super().__init__(**kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, **kwargs)
self.get_bec_shortcuts()

View File

@@ -29,6 +29,7 @@ class DeviceSignalInputBase(BECWidget):
signal object based on the current text of the widget.
"""
RPC = False
_filter_handler = {
Kind.hinted: "include_hinted_signals",
Kind.normal: "include_normal_signals",

View File

@@ -47,8 +47,7 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name

View File

@@ -53,8 +53,7 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

@@ -40,8 +40,7 @@ class SignalComboBox(DeviceSignalInputBase, QComboBox):
arg_name: str | None = None,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name

View File

@@ -42,8 +42,7 @@ class SignalLineEdit(DeviceSignalInputBase, QLineEdit):
**kwargs,
):
self._is_valid_input = False
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._accent_colors = get_accent_colors()
self.completer = QCompleter(self)
self.setCompleter(self.completer)

View File

@@ -41,6 +41,10 @@ class ScanControlConfig(ConnectionConfig):
class ScanControl(BECWidget, QWidget):
"""
Widget to submit new scans to the queue.
"""
PLUGIN = True
ICON_NAME = "tune"
ARG_BOX_POSITION: int = 2
@@ -65,8 +69,7 @@ class ScanControl(BECWidget, QWidget):
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self._hide_add_remove_buttons = False

View File

@@ -1,4 +1,4 @@
""" Module for DapComboBox widget class to select a DAP model from a combobox. """
"""Module for DapComboBox widget class to select a DAP model from a combobox."""
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
@@ -16,7 +16,7 @@ class DapComboBox(BECWidget, QWidget):
Args:
parent: Parent widget.
client: BEC client object.
gui_id: GUI ID.
gui_id: GUI ID.--
default: Default device name.
"""
@@ -44,8 +44,7 @@ class DapComboBox(BECWidget, QWidget):
default_fit: str | None = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)
@@ -155,7 +154,9 @@ class DapComboBox(BECWidget, QWidget):
def populate_fit_model_combobox(self):
"""Populate the fit_model_combobox with the devices."""
# pylint: disable=protected-access
self.available_models = [model for model in self.client.dap._available_dap_plugins.keys()]
self.available_models = [
model for model in self.client.dap._available_dap_plugins.keys()
]
self.fit_model_combobox.clear()
self.fit_model_combobox.addItems(self.available_models)

View File

@@ -0,0 +1,193 @@
import inspect
from bec_lib.signature_serializer import deserialize_dtype
from bec_server.data_processing.dap_framework_refactoring.dap_blocks import (
BlockWithLotsOfArgs,
DAPBlock,
GradientBlock,
SmoothBlock,
)
from pydantic.fields import FieldInfo
from PySide6.QtWidgets import (
QCheckBox,
QDoubleSpinBox,
QLabel,
QLayout,
QRadioButton,
QScrollArea,
)
from qtpy.QtCore import QMimeData, Qt, Signal
from qtpy.QtGui import QDrag, QPixmap
from qtpy.QtWidgets import (
QApplication,
QHBoxLayout,
QMainWindow,
QVBoxLayout,
QWidget,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.widgets.editors.scan_metadata._metadata_widgets import widget_from_type
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
from tests.unit_tests.test_scan_metadata import metadata_widget
class DragItem(ExpandableGroupFrame):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setContentsMargins(25, 5, 25, 5)
self._layout = QVBoxLayout()
self.set_layout(self._layout)
def mouseMoveEvent(self, e):
if e.buttons() == Qt.MouseButton.LeftButton:
drag = QDrag(self)
mime = QMimeData()
drag.setMimeData(mime)
pixmap = QPixmap(self.size())
self.render(pixmap)
drag.setPixmap(pixmap)
drag.exec(Qt.DropAction.MoveAction)
class DragWidget(QWidget):
"""
Generic list sorting handler.
"""
orderChanged = Signal(list)
def __init__(self, *args, orientation=Qt.Orientation.Vertical, **kwargs):
super().__init__(*args, **kwargs)
self.setAcceptDrops(True)
# Store the orientation for drag checks later.
self.orientation = orientation
if self.orientation == Qt.Orientation.Vertical:
self.blayout = QVBoxLayout()
else:
self.blayout = QHBoxLayout()
self.setLayout(self.blayout)
def dragEnterEvent(self, e):
e.accept()
def dropEvent(self, e):
pos = e.position()
widget = e.source()
self.blayout.removeWidget(widget)
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w = self.blayout.itemAt(n).widget()
if self.orientation == Qt.Orientation.Vertical:
# Drag drop vertically.
drop_here = pos.y() < w.y() + w.size().height() // 2
else:
# Drag drop horizontally.
drop_here = pos.x() < w.x() + w.size().width() // 2
if drop_here:
break
else:
# We aren't on the left hand/upper side of any widget,
# so we're at the end. Increment 1 to insert after.
n += 1
self.blayout.insertWidget(n, widget)
self.orderChanged.emit(self.get_item_data())
e.accept()
def add_item(self, item):
self.blayout.addWidget(item)
def get_item_data(self):
data = []
for n in range(self.blayout.count()):
# Get the widget at each index in turn.
w: "DAPBlockWidget" = self.blayout.itemAt(n).widget()
data.append(w._title.text())
return data
class DAPBlockWidget(BECWidget, DragItem):
def __init__(
self,
parent=None,
content: type[DAPBlock] = None,
client=None,
gui_id: str | None = None,
**kwargs,
):
super().__init__(
parent=parent,
client=client,
gui_id=gui_id,
title=content.__name__,
**kwargs,
)
self._content = content
self.add_form(self._content)
def add_form(self, block_type: type[DAPBlock]):
run_signature = inspect.signature(block_type.run)
self._title.setText(block_type.__name__)
layout = self._contents.layout()
if layout is None:
return
self._add_widgets_for_signature(layout, run_signature)
def _add_widgets_for_signature(self, layout: QLayout, signature: inspect.Signature):
for arg_name, arg_spec in signature.parameters.items():
annotation: str | type = arg_spec.annotation
if isinstance(annotation, str):
annotation = deserialize_dtype(annotation) or annotation
w = QWidget()
w.setLayout(QHBoxLayout())
w.layout().addWidget(QLabel(arg_name))
w.layout().addWidget(
widget_from_type(annotation)(
FieldInfo(
annotation=annotation
) # FIXME this class should not be initialised directly...
)
)
w.layout().addWidget(QLabel(str(annotation)))
layout.addWidget(w)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.drag = DragWidget(orientation=Qt.Orientation.Vertical)
for block_type in [SmoothBlock, GradientBlock, BlockWithLotsOfArgs] * 2:
item = DAPBlockWidget(content=block_type)
self.drag.add_item(item)
# Print out the changed order.
self.drag.orderChanged.connect(print)
container = QWidget()
layout = QVBoxLayout()
layout.addStretch(1)
layout.addWidget(self.drag)
layout.addStretch(1)
container.setLayout(layout)
self.setCentralWidget(container)
if __name__ == "__main__":
app = QApplication([])
w = MainWindow()
w.show()
app.exec()

View File

@@ -0,0 +1,6 @@
"""Panel to compose DAP task runs
- new ones are added as tabs
- can be enabled and disabled, continuous and oneoff
- fill in extra kwargs using thing from MD widget
- output to topic for the name, which looks like a data topic
"""

View File

@@ -17,6 +17,7 @@ class LMFitDialog(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "monitoring"
RPC = False
# Signal to emit the currently selected fit curve_id
selected_fit = Signal(str)
# Signal to emit a move action in form of a tuple (param_name, value)
@@ -43,10 +44,8 @@ class LMFitDialog(BECWidget, QWidget):
gui_id (str): GUI ID.
ui_file (str): The UI file to be loaded.
"""
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")
self._ui_file = ui_file
self.target_widget = target_widget

View File

@@ -83,7 +83,6 @@ class ClearableBoolEntry(QWidget):
class MetadataWidget(QWidget):
valueChanged = Signal()
def __init__(self, info: FieldInfo, parent: QWidget | None = None) -> None:
@@ -250,7 +249,9 @@ def widget_from_type(annotation: type | None) -> Callable[[FieldInfo], MetadataW
if annotation in [bool, bool | None]:
return BoolMetadataField
else:
logger.warning(f"Type {annotation} is not (yet) supported in metadata form creation.")
logger.warning(
f"Type {annotation} is not (yet) supported in metadata form creation."
)
return StrMetadataField

View File

@@ -35,7 +35,7 @@ if TYPE_CHECKING:
logger = bec_logger.logger
class ScanMetadata(BECWidget, QWidget):
class PydanticModelForm(BECWidget, QWidget):
"""Dynamically generates a form for inclusion of metadata for a scan. Uses the
metadata schema registry supplied in the plugin repo to find pydantic models
associated with the scan type. Sets limits for numerical values if specified."""
@@ -45,6 +45,7 @@ class ScanMetadata(BECWidget, QWidget):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
RPC = False
def __init__(
self,
@@ -54,8 +55,7 @@ class ScanMetadata(BECWidget, QWidget):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(client=client, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, **kwargs)
self.set_schema(scan_name)
@@ -75,7 +75,9 @@ class ScanMetadata(BECWidget, QWidget):
self._new_grid_layout()
self._grid_container.addLayout(self._md_grid_layout)
self._additional_md_box = ExpandableGroupFrame("Additional metadata", expanded=False)
self._additional_md_box = ExpandableGroupFrame(
"Additional metadata", expanded=False
)
self._layout.addWidget(self._additional_md_box)
self._additional_md_box_layout = QHBoxLayout()
self._additional_md_box.set_layout(self._additional_md_box_layout)
@@ -129,7 +131,9 @@ class ScanMetadata(BECWidget, QWidget):
self._populate()
def _populate(self):
self._additional_metadata.update_disallowed_keys(list(self._md_schema.model_fields.keys()))
self._additional_metadata.update_disallowed_keys(
list(self._md_schema.model_fields.keys())
)
for i, (field_name, info) in enumerate(self._md_schema.model_fields.items()):
self._add_griditem(field_name, info, i)
@@ -146,7 +150,11 @@ class ScanMetadata(BECWidget, QWidget):
def _dict_from_grid(self) -> dict[str, str | int | float | Decimal | bool]:
grid = self._md_grid_layout
return {
grid.itemAtPosition(i, 0).widget().property("_model_field_name"): grid.itemAtPosition(i, 1).widget().getValue() # type: ignore # we only add 'MetadataWidget's here
grid.itemAtPosition(i, 0)
.widget()
.property("_model_field_name"): grid.itemAtPosition(i, 1)
.widget()
.getValue() # type: ignore # we only add 'MetadataWidget's here
for i in range(grid.rowCount())
}
@@ -182,6 +190,9 @@ class ScanMetadata(BECWidget, QWidget):
self._additional_md_box.setVisible(not hide)
class ScanMetadata(PydanticModelForm): ...
if __name__ == "__main__": # pragma: no cover
from unittest.mock import patch
@@ -190,15 +201,21 @@ if __name__ == "__main__": # pragma: no cover
from bec_widgets.utils.colors import set_theme
class ExampleSchema1(BasicScanMetadata):
abc: int = Field(gt=0, lt=2000, description="Heating temperature abc", title="A B C")
foo: str = Field(max_length=12, description="Sample database code", default="DEF123")
abc: int = Field(
gt=0, lt=2000, description="Heating temperature abc", title="A B C"
)
foo: str = Field(
max_length=12, description="Sample database code", default="DEF123"
)
xyz: Decimal = Field(decimal_places=4)
baz: bool
class ExampleSchema2(BasicScanMetadata):
checkbox_up_top: bool
checkbox_again: bool = Field(
title="Checkbox Again", description="this one defaults to True", default=True
title="Checkbox Again",
description="this one defaults to True",
default=True,
)
different_items: int | None = Field(
None, description="This is just one different item...", gt=-100, lt=0
@@ -211,9 +228,12 @@ if __name__ == "__main__": # pragma: no cover
with patch(
"bec_lib.metadata_schema._get_metadata_schema_registry",
lambda: {"scan1": ExampleSchema1, "scan2": ExampleSchema2, "scan3": ExampleSchema3},
lambda: {
"scan1": ExampleSchema1,
"scan2": ExampleSchema2,
"scan3": ExampleSchema3,
},
):
app = QApplication([])
w = QWidget()
selection = QComboBox()

View File

@@ -49,8 +49,7 @@ class TextBox(BECWidget, QWidget):
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
self.layout.addWidget(self.text_box_text_edit)

View File

@@ -26,8 +26,7 @@ class WebsiteWidget(BECWidget, QWidget):
def __init__(
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
self.website = QWebEngineView()

View File

@@ -144,10 +144,10 @@ class Minesweeper(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "videogame_asset"
USER_ACCESS = []
RPC = False
def __init__(self, parent=None, *args, **kwargs):
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, *args, **kwargs)
self._ui_initialised = False
self._timer_start_num_seconds = 0

View File

@@ -41,6 +41,10 @@ class ImageConfig(ConnectionConfig):
class Image(PlotBase):
"""
Image widget for displaying 2D data.
"""
PLUGIN = True
RPC = True
ICON_NAME = "image"
@@ -125,16 +129,15 @@ class Image(PlotBase):
popups: bool = True,
**kwargs,
):
self._main_image = ImageItem(parent_image=self)
self._color_bar = None
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
self.gui_id = config.gui_id
self._color_bar = None
self._main_image = ImageItem()
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("Image")
self._main_image = ImageItem(parent_image=self, parent_id=self.gui_id)
self.plot_item.addItem(self._main_image)
self.scan_id = None
@@ -914,10 +917,14 @@ class Image(PlotBase):
"""
Disconnect the image update signals and clean up the image.
"""
# Main Image cleanup
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
self._main_image.config.monitor = None
self.plot_item.removeItem(self._main_image)
self._main_image = None
# Colorbar Cleanup
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
@@ -926,6 +933,10 @@ class Image(PlotBase):
self._color_bar.deleteLater()
self._color_bar = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
super().cleanup()

View File

@@ -82,10 +82,12 @@ class ImageItem(BECConnector, pg.ImageItem):
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
if parent_image is not None:
self.set_parent(parent_image)
else:
self.parent_image = None
self.parent_id = None
super().__init__(config=config, gui_id=gui_id, **kwargs)
self.raw_data = None
self.buffer = []
@@ -94,6 +96,13 @@ class ImageItem(BECConnector, pg.ImageItem):
# Image processor will handle any setting of data
self._image_processor = ImageProcessor(config=self.config.processing)
def set_parent(self, parent: BECConnector):
self.parent_image = parent
self.parent_id = parent.gui_id
def parent(self):
return self.parent_image
def set_data(self, data: np.ndarray):
self.raw_data = data
self._process_image()

View File

@@ -28,7 +28,9 @@ class MonitorSelectionToolbarBundle(ToolbarBundle):
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=[ReadoutPriority.ASYNC]
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")

View File

@@ -83,6 +83,10 @@ class MotorMapConfig(ConnectionConfig):
class MotorMap(PlotBase):
"""
Motor map widget for plotting motor positions in 2D including a trace of the last points.
"""
PLUGIN = True
RPC = True
ICON_NAME = "my_location"
@@ -161,9 +165,6 @@ class MotorMap(PlotBase):
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("MotorMap")
# Default values for PlotBase
self.x_grid = True
self.y_grid = True
@@ -794,6 +795,10 @@ class MotorMap(PlotBase):
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
def cleanup(self):
self.motor_selection_bundle.cleanup()
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@@ -20,7 +20,6 @@ class MotorMapSettings(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MotorMapSettings")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "motor_map_settings.ui"), self)

View File

@@ -27,14 +27,18 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y = DeviceComboBox(
parent=self.target_widget, device_filter=[BECDeviceFilter.POSITIONER]
)
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
@@ -58,3 +62,9 @@ class MotorSelectionToolbarBundle(ToolbarBundle):
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)
def cleanup(self):
self.motor_x.close()
self.motor_x.deleteLater()
self.motor_y.close()
self.motor_y.deleteLater()

View File

@@ -45,6 +45,10 @@ class MultiWaveformConfig(ConnectionConfig):
class MultiWaveform(PlotBase):
"""
MultiWaveform widget for displaying multiple waveforms emitted by a single signal.
"""
PLUGIN = True
RPC = True
ICON_NAME = "ssid_chart"
@@ -126,9 +130,6 @@ class MultiWaveform(PlotBase):
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("MultiWaveform")
# Scan Data
self.old_scan_id = None
self.scan_id = None
@@ -499,3 +500,9 @@ class MultiWaveform(PlotBase):
self.monitor_selection_bundle.colormap_widget.blockSignals(True)
self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
self.monitor_selection_bundle.colormap_widget.blockSignals(False)
def cleanup(self):
self._disconnect_monitor()
self.clear_curves()
self.monitor_selection_bundle.cleanup()
super().cleanup()

View File

@@ -20,7 +20,6 @@ class MultiWaveformControlPanel(SettingWidget):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MultiWaveformControlPanel")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "multi_waveform_controls.ui"), self)

View File

@@ -29,7 +29,9 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
# Monitor Selection
self.monitor = DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=ReadoutPriority.ASYNC,
parent_id=self.target_widget.gui_id,
)
self.monitor.addItem("", None)
self.monitor.setCurrentText("")
@@ -38,7 +40,7 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
# Colormap Selection
self.colormap_widget = BECColorMapWidget(cmap="magma")
self.colormap_widget = BECColorMapWidget(cmap="magma", parent_id=self.target_widget.gui_id)
self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
@@ -56,3 +58,10 @@ class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
@SafeSlot(str)
def change_colormap(self, colormap: str):
self.target_widget.color_palette = colormap
def cleanup(self):
"""
Cleanup the toolbar bundle.
"""
self.monitor.close()
self.monitor.deleteLater()

View File

@@ -69,16 +69,14 @@ class PlotBase(BECWidget, QWidget):
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = False,
popups: bool = True,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
# For PropertyManager identification
self.setObjectName("PlotBase")
self.get_bec_shortcuts()
# Layout Management
@@ -172,6 +170,9 @@ class PlotBase(BECWidget, QWidget):
# hide some options by default
self.toolbar.toggle_action_visibility("fps_monitor", False)
# Get default viewbox state
self.mouse_bundle.get_viewbox_mode()
def add_side_menus(self):
"""Adds multiple menus to the side panel."""
# Setting Axis Widget
@@ -1018,7 +1019,7 @@ if __name__ == "__main__": # pragma: no cover:
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
window = DemoPlotBase()
window = PlotBase()
window.show()
sys.exit(app.exec_())

View File

@@ -77,13 +77,16 @@ class ScatterCurve(BECConnector, pg.PlotDataItem):
else:
self.config = config
name = config.label
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.parent_id = self.parent_item.gui_id
super().__init__(name=name, config=config, gui_id=gui_id, **kwargs)
self.data_z = None # color scaling needs to be cashed for changing colormap
self.apply_config()
def parent(self):
return self.parent_item
def apply_config(self, config: dict | ScatterCurveConfig | None = None, **kwargs) -> None:
"""
Apply the configuration to the curve.

View File

@@ -14,7 +14,7 @@ from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.utils.toolbar import MaterialIconAction
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode
from bec_widgets.widgets.plots.scatter_waveform.scatter_curve import (
ScatterCurve,
ScatterCurveConfig,
@@ -107,15 +107,14 @@ class ScatterWaveform(PlotBase):
):
if config is None:
config = ScatterWaveformConfig(widget_class=self.__class__.__name__)
# Specific GUI elements
self.scatter_dialog = None
self.scatter_curve_settings = None
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._main_curve = ScatterCurve(parent_item=self)
# For PropertyManager identification
self.setObjectName("ScatterWaveform")
# Specific GUI elements
self.scatter_dialog = None
# Scan Data
self.old_scan_id = None
@@ -130,24 +129,27 @@ class ScatterWaveform(PlotBase):
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
)
self._init_scatter_curve_settings()
if self.ui_mode == UIMode.SIDE:
self._init_scatter_curve_settings()
self.update_with_scan_history(-1)
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_scatter_curve_settings(self):
"""
Initialize the scatter curve settings menu.
"""
scatter_curve_settings = ScatterCurveSettings(parent=self, target_widget=self, popup=False)
self.scatter_curve_settings = ScatterCurveSettings(
parent=self, target_widget=self, popup=False
)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=scatter_curve_settings,
widget=self.scatter_curve_settings,
title="Scatter Curve Settings",
)
@@ -463,17 +465,30 @@ class ScatterWaveform(PlotBase):
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is not None:
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
else:
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self.sync_signal_update.emit()
return
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self.sync_signal_update.emit()
return
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
self.sync_signal_update.emit()
@@ -489,6 +504,21 @@ class ScatterWaveform(PlotBase):
self.crosshair.clear_markers()
self._main_curve.clear()
def cleanup(self):
"""
Cleanup the widget and disconnect all signals.
"""
if self.scatter_dialog is not None:
self.scatter_dialog.close()
self.scatter_dialog.deleteLater()
if self.scatter_curve_settings is not None:
self.scatter_curve_settings.cleanup()
self.bec_dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.disconnect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
self.plot_item.removeItem(self._main_curve)
self._main_curve = None
super().cleanup()
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):

View File

@@ -15,7 +15,6 @@ class ScatterCurveSettings(SettingWidget):
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
self.setObjectName("ScatterCurveSettings")
current_path = os.path.dirname(__file__)
if popup:
@@ -123,3 +122,17 @@ class ScatterCurveSettings(SettingWidget):
color_map=color_map,
validate_bec=validate_bec,
)
def cleanup(self):
self.ui.x_name.close()
self.ui.x_name.deleteLater()
self.ui.x_entry.close()
self.ui.x_entry.deleteLater()
self.ui.y_name.close()
self.ui.y_name.deleteLater()
self.ui.y_entry.close()
self.ui.y_entry.deleteLater()
self.ui.z_name.close()
self.ui.z_name.deleteLater()
self.ui.z_entry.close()
self.ui.z_entry.deleteLater()

View File

@@ -16,7 +16,6 @@ class AxisSettings(SettingWidget):
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
self.setObjectName("AxisSettings")
current_path = os.path.dirname(__file__)
if popup:
form = UILoader().load_ui(

View File

@@ -56,9 +56,6 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
# Give some time to check the state
QTimer.singleShot(10, self.get_viewbox_mode)
def get_viewbox_mode(self):
"""
Returns the current interaction mode of a PyQtGraph ViewBox and sets the corresponding action.

View File

@@ -61,6 +61,7 @@ class Curve(BECConnector, pg.PlotDataItem):
"remove",
"_rpc_id",
"_config_dict",
"_get_displayed_data",
"set",
"set_data",
"set_color",
@@ -90,15 +91,22 @@ class Curve(BECConnector, pg.PlotDataItem):
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.parent_id = self.parent_item.gui_id
object_name = name.replace("-", "_") if name else None
super().__init__(name=name, object_name=object_name, config=config, gui_id=gui_id, **kwargs)
self.apply_config()
self.dap_params = None
self.dap_summary = None
self.slice_index = None
if kwargs:
self.set(**kwargs)
# Activate setClipToView, to boost performance for large datasets per default
self.setClipToView(True)
def parent(self):
return self.parent_item
def apply_config(self, config: dict | CurveConfig | None = None, **kwargs) -> None:
"""
@@ -303,14 +311,14 @@ class Curve(BECConnector, pg.PlotDataItem):
self.apply_config()
self.parent_item.update_with_scan_history(-1)
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
def get_data(self) -> tuple[np.ndarray | None, np.ndarray | None]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
try:
x_data, y_data = self.getData()
x_data, y_data = self.getOriginalDataset()
except TypeError:
x_data, y_data = np.array([]), np.array([])
return x_data, y_data
@@ -326,3 +334,15 @@ class Curve(BECConnector, pg.PlotDataItem):
# self.parent_item.removeItem(self)
self.parent_item.remove_curve(self.name())
super().remove()
def _get_displayed_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the displayed data of the curve.
Returns:
tuple[np.ndarray, np.ndarray]: The x and y data of the curve.
"""
x_data, y_data = self.getData()
if x_data is None or y_data is None:
return np.array([]), np.array([])
return x_data, y_data

View File

@@ -7,6 +7,7 @@ from qtpy.QtWidgets import (
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QSizePolicy,
QVBoxLayout,
QWidget,
@@ -27,7 +28,6 @@ class CurveSetting(SettingWidget):
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("CurveSetting")
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
@@ -51,7 +51,10 @@ class CurveSetting(SettingWidget):
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit()
self.device_x = DeviceLineEdit(parent=self)
self.signal_x_label = QLabel("Signal")
self.signal_x = QLineEdit()
self._get_x_mode_from_waveform()
self.switch_x_device_selection()
@@ -63,6 +66,8 @@ class CurveSetting(SettingWidget):
self.x_axis_box.layout.addWidget(self.spacer)
self.x_axis_box.layout.addWidget(self.device_x_label)
self.x_axis_box.layout.addWidget(self.device_x)
self.x_axis_box.layout.addWidget(self.signal_x_label)
self.x_axis_box.layout.addWidget(self.signal_x)
self.x_axis_box.setFixedHeight(80)
self.layout.addWidget(self.x_axis_box)
@@ -77,6 +82,7 @@ class CurveSetting(SettingWidget):
if self.mode_combo.currentText() == "device":
self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"])
self.signal_x.setText(self.target_widget.x_axis_mode["entry"])
else:
self.device_x.setEnabled(False)
@@ -98,6 +104,9 @@ class CurveSetting(SettingWidget):
"""
if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text()
signal_x = self.signal_x.text()
if signal_x != "":
self.target_widget.x_entry = signal_x
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()
@@ -107,3 +116,10 @@ class CurveSetting(SettingWidget):
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
self.curve_manager.refresh_from_waveform()
self._get_x_mode_from_waveform()
def cleanup(self):
"""Cleanup the widget."""
self.device_x.close()
self.device_x.deleteLater()
self.curve_manager.close()
self.curve_manager.deleteLater()

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
@@ -36,6 +37,9 @@ if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
@@ -110,11 +114,16 @@ class CurveRow(QTreeWidgetItem):
self.curve_tree = tree.parent() # The CurveTree widget
self.curve_tree.all_items.append(self) # Track stable ordering
# BEC user input
self.device_edit = None
self.dap_combo = None
self.dev = device_manager
self.entry_validator = EntryValidator(self.dev)
self.config = config or CurveConfig()
self.source = self.config.source
self.dap_rows = []
# Create column 0 (Actions)
self._init_actions()
@@ -155,8 +164,8 @@ class CurveRow(QTreeWidgetItem):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit()
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
self.device_edit = DeviceLineEdit(parent=self.tree)
self.entry_edit = QLineEdit(parent=self.tree) # TODO in future will be signal line edit
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
@@ -168,7 +177,7 @@ class CurveRow(QTreeWidgetItem):
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
self.tree.setItemWidget(self, 1, self.label_widget)
self.dap_combo = DapComboBox()
self.dap_combo = DapComboBox(parent=self.tree)
self.dap_combo.populate_fit_model_combobox()
# If config.signal has a dap
if self.config.signal and self.config.signal.dap:
@@ -258,15 +267,31 @@ class CurveRow(QTreeWidgetItem):
def remove_self(self):
"""Remove this row from the tree and from the parent's item list."""
# If top-level:
# Recursively remove all child rows first
for i in reversed(range(self.childCount())):
child = self.child(i)
if isinstance(child, CurveRow):
child.remove_self()
# Clean up the widget references if they still exist
if getattr(self, "device_edit", None) is not None:
self.device_edit.close()
self.device_edit.deleteLater()
self.device_edit = None
if getattr(self, "dap_combo", None) is not None:
self.dap_combo.close()
self.dap_combo.deleteLater()
self.dap_combo = None
# Remove the item from the tree widget
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
self.tree.takeTopLevelItem(index)
else:
# If child item
if self.parent_item:
self.parent_item.removeChild(self)
# Also remove from all_items
elif self.parent_item:
self.parent_item.removeChild(self)
# Finally, remove self from the registration list in the curve tree
curve_tree = self.tree.parent()
if self in curve_tree.all_items:
curve_tree.all_items.remove(self)
@@ -320,6 +345,10 @@ class CurveRow(QTreeWidgetItem):
return self.config.model_dump()
def closeEvent(self, event) -> None:
logger.info(f"CurveRow closeEvent: {self.config.label}")
return super().closeEvent(event)
class CurveTree(BECWidget, QWidget):
"""A tree widget that manages device and DAP curves."""
@@ -334,11 +363,11 @@ class CurveTree(BECWidget, QWidget):
client=None,
gui_id: str | None = None,
waveform: Waveform | None = None,
**kwargs,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.waveform = waveform
if self.waveform and hasattr(self.waveform, "color_palette"):
@@ -535,3 +564,13 @@ class CurveTree(BECWidget, QWidget):
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)
def cleanup(self):
"""Cleanup the widget."""
all_items = list(self.all_items)
for item in all_items:
item.remove_self()
def closeEvent(self, event):
self.cleanup()
return super().closeEvent(event)

View File

@@ -10,7 +10,7 @@ from bec_lib import bec_logger, messages
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QTimer, Signal
from qtpy.QtWidgets import QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
from qtpy.QtWidgets import QApplication, QDialog, QHBoxLayout, QMainWindow, QVBoxLayout, QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_signal_proxy import BECSignalProxy
@@ -38,6 +38,10 @@ class WaveformConfig(ConnectionConfig):
class Waveform(PlotBase):
"""
Widget for plotting waveforms.
"""
PLUGIN = True
RPC = True
ICON_NAME = "show_chart"
@@ -82,7 +86,6 @@ class Waveform(PlotBase):
"legend_label_size",
"legend_label_size.setter",
# Waveform Specific RPC Access
"__getitem__",
"curves",
"x_mode",
"x_mode.setter",
@@ -128,12 +131,10 @@ class Waveform(PlotBase):
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("Waveform")
# Curve data
self._sync_curves = []
self._async_curves = []
self._slice_index = None
self._dap_curves = []
self._mode: Literal["none", "sync", "async", "mixed"] = "none"
@@ -178,9 +179,20 @@ class Waveform(PlotBase):
# for updating a color scheme of curves
self._connect_to_theme_change()
# To fix the ViewAll action with clipToView activated
self._connect_viewbox_menu_actions()
def __getitem__(self, key: int | str):
return self.get_curve(key)
def _connect_viewbox_menu_actions(self):
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
menu = self.plot_item.vb.menu
# Find and replace "View All" action
for action in menu.actions():
if action.text() == "View All":
# Disconnect the default autoRange action
action.triggered.disconnect()
# Connect to the custom reset_view method
action.triggered.connect(self._reset_view)
break
################################################################################
# Widget Specific GUI interactions
@@ -219,6 +231,25 @@ class Waveform(PlotBase):
)
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_dap_summary_popup)
@SafeSlot()
def _reset_view(self):
"""
Custom _reset_view method to fix ViewAll action in toolbar.
Due to setting clipToView to True on the curves, the autoRange() method
of the ViewBox does no longer work as expected. This method deactivates the
setClipToView for all curves, calls autoRange() to circumvent that issue.
Afterwards, it re-enables the setClipToView for all curves again.
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
"""
for curve in self._async_curves + self._sync_curves:
curve.setClipToView(False)
self.plot_item.vb.autoRange()
self.auto_range_x = True
self.auto_range_y = True
for curve in self._async_curves + self._sync_curves:
curve.setClipToView(True)
################################################################################
# Roi manager
@@ -282,6 +313,8 @@ class Waveform(PlotBase):
"""
Slot for when the axis settings dialog is closed.
"""
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
self.toolbar.widgets["curve"].action.setChecked(False)
@@ -420,12 +453,41 @@ class Waveform(PlotBase):
@x_mode.setter
def x_mode(self, value: str):
self.x_axis_mode["name"] = value
if value not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(value, None)
self._switch_x_axis_item(mode=value)
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
@SafeProperty(str)
def x_entry(self) -> str | None:
"""
The x signal name.
"""
return self.x_axis_mode["entry"]
@x_entry.setter
def x_entry(self, value: str | None):
"""
Set the x signal name.
Args:
value(str|None): The x signal name to set.
"""
if value is None:
return
if self.x_axis_mode["name"] in ["auto", "index", "timestamp"]:
logger.warning("Cannot set x_entry when x_mode is not 'device'.")
return
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(self.x_mode, value)
self._switch_x_axis_item(mode="device")
self.async_signal_update.emit()
self.sync_signal_update.emit()
self.plot_item.enableAutoRange(x=True)
self.round_plot_widget.apply_plot_widget_style()
@SafeProperty(str)
def color_palette(self) -> str:
"""
@@ -796,6 +858,9 @@ class Waveform(PlotBase):
Clear all curves from the plot widget.
"""
curve_list = self.curves
self._dap_curves = []
self._sync_curves = []
self._async_curves = []
for curve in curve_list:
self.remove_curve(curve.name())
if self.crosshair is not None:
@@ -876,6 +941,7 @@ class Waveform(PlotBase):
self.on_async_readback,
MessageEndpoints.device_async_readback(self.scan_id, curve.name()),
)
curve.rpc_register.remove_rpc(curve)
# Remove itself from the DAP summary only for side panels
if (
@@ -947,6 +1013,7 @@ class Waveform(PlotBase):
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id) # live scan
self._slice_index = None # Reset the slice index
self._mode = self._categorise_device_curves()
@@ -1065,14 +1132,27 @@ class Waveform(PlotBase):
if len(np.shape(device_data)) > 1:
device_data = device_data[-1, :]
x_data = self._get_x_data(device_name, device_entry)
if device_data is None:
logger.warning(f"Async data for curve {curve.name()} is None.")
continue
# Async curves only support plotting vs index or other device
if self.x_axis_mode["name"] in ["timestamp", "index", "auto"]:
device_data_x = np.linspace(0, len(device_data) - 1, len(device_data))
else:
# Fetch data from signal instead
device_data_x = self._get_x_data(device_name, device_entry)
# Fallback to 'index' in case data is not of equal length
if len(device_data_x) != len(device_data):
logger.warning(
f"Async data for curve {curve.name()} and x_axis {device_entry} is not of equal length. Falling back to 'index' plotting."
)
device_data_x = np.linspace(0, len(device_data) - 1, len(device_data))
self._auto_adjust_async_curve_settings(curve, len(device_data))
curve.setData(device_data_x, device_data)
# If there's actual data, set it
if device_data is not None:
if x_data is not None:
curve.setData(x_data, device_data)
else:
curve.setData(device_data)
self.request_dap_update.emit()
def _setup_async_curve(self, curve: Curve):
@@ -1101,50 +1181,131 @@ class Waveform(PlotBase):
@SafeSlot(dict, dict)
def on_async_readback(self, msg, metadata):
"""
Get async data readback.
Get async data readback. This code needs to be fast, therefor we try
to reduce the number of copies in between cycles. Be careful when refactoring
this part as it will affect the performance of the async readback.
Async curves support plotting against 'index' or other 'device_signal'. No 'auto' or 'timestamp'.
The fallback mechanism for 'auto' and 'timestamp' is to use the 'index'.
Note:
We create data_plot_x and data_plot_y and modify them within this function
to avoid creating new arrays. This is important for performance.
Support update instructions are 'add', 'add_slice', and 'replace'.
Args:
msg(dict): Message with the async data.
metadata(dict): Metadata of the message.
"""
y_data = None
x_data = None
instruction = metadata.get("async_update", {}).get("type")
if instruction not in ["add", "add_slice", "replace"]:
logger.warning(f"Invalid async update instruction: {instruction}")
return
max_shape = metadata.get("async_update", {}).get("max_shape", [])
plot_mode = self.x_axis_mode["name"]
for curve in self._async_curves:
y_entry = curve.config.signal.entry
x_name = self.x_axis_mode["name"]
for device, async_data in msg["signals"].items():
if device == y_entry:
data_plot = async_data["value"]
if instruction == "add":
if len(max_shape) > 1:
if len(data_plot.shape) > 1:
data_plot = data_plot[-1, :]
else:
x_data, y_data = curve.get_data()
x_data = None # Reset x_data
# Get the curve data
async_data = msg["signals"].get(curve.config.signal.entry, None)
if async_data is None:
continue
# y-data
data_plot_y = async_data["value"]
if data_plot_y is None:
logger.warning(f"Async data for curve {curve.name()} is None.")
continue
# Ensure we have numpy array for data_plot_y
data_plot_y = np.asarray(data_plot_y)
# Add
if instruction == "add":
if len(max_shape) > 1:
if len(data_plot_y.shape) > 1:
data_plot_y = data_plot_y[-1, :]
else:
x_data, y_data = curve.get_data()
if y_data is not None:
data_plot_y = np.hstack((y_data, data_plot_y))
# Add slice
if instruction == "add_slice":
current_slice_id = metadata.get("async_update", {}).get("index")
if current_slice_id != curve.slice_index:
curve.slice_index = current_slice_id
else:
x_data, y_data = curve.get_data()
if y_data is not None:
data_plot_y = np.hstack((y_data, data_plot_y))
# Replace is trivial, no need to modify data_plot_y
# Get x data for plotting
if plot_mode in ["index", "auto", "timestamp"]:
data_plot_x = np.linspace(0, len(data_plot_y) - 1, len(data_plot_y))
self._auto_adjust_async_curve_settings(curve, len(data_plot_y))
curve.setData(data_plot_x, data_plot_y)
# Move on in the loop
continue
# x_axis_mode is device signal
# Only consider device signals that are async for now, fallback is index
x_device_entry = self.x_axis_mode["entry"]
async_data = msg["signals"].get(x_device_entry, None)
# Make sure the signal exists, otherwise fall back to index
if async_data is None:
# Try to grab the data from device signals
data_plot_x = self._get_x_data(plot_mode, x_device_entry)
else:
data_plot_x = np.asarray(async_data["value"])
if x_data is not None:
data_plot_x = np.hstack((x_data, data_plot_x))
# Fallback incase data is not of equal length
if len(data_plot_x) != len(data_plot_y):
logger.warning(
f"Async data for curve {curve.name()} and x_axis {x_device_entry} is not of equal length. Falling back to 'index' plotting."
)
data_plot_x = np.linspace(0, len(data_plot_y) - 1, len(data_plot_y))
# Plot the data
self._auto_adjust_async_curve_settings(curve, len(data_plot_y))
curve.setData(data_plot_x, data_plot_y)
if y_data is not None:
new_data = np.hstack((y_data, data_plot)) # TODO check performance
else:
new_data = data_plot
if x_name == "timestamp":
if x_data is not None:
x_data = np.hstack((x_data, async_data["timestamp"]))
else:
x_data = async_data["timestamp"]
# FIXME x axis wrong if timestamp switched during scan
curve.setData(x_data, new_data)
else: # this means index as x
curve.setData(new_data)
elif instruction == "replace":
if x_name == "timestamp":
x_data = async_data["timestamp"]
curve.setData(x_data, data_plot)
else:
curve.setData(data_plot)
self.request_dap_update.emit()
def _auto_adjust_async_curve_settings(
self,
curve: Curve,
data_length: int,
limit: int = 1000,
method: Literal["subsample", "mean", "peak"] | None = "peak",
) -> None:
"""
Based on the length of the data this method will adjust the plotting settings of
Curve items, by deactivating the symbol and activating downsampling auto, method='mean',
if the data length exceeds N points. If the data length is less than N points, the
symbol will be activated and downsampling will be deactivated. Maximum points will be
5x the limit.
Args:
curve(Curve): The curve to adjust.
data_length(int): The length of the data.
limit(int): The limit of the data length to activate the downsampling.
"""
if limit <= 1:
logger.warning("Limit must be greater than 1.")
return
if data_length > limit:
if curve.config.symbol is not None:
curve.set_symbol(None)
if curve.config.pen_width > 3:
curve.set_pen_width(3)
curve.setDownsampling(ds=None, auto=True, method=method)
curve.setClipToView(True)
elif data_length <= limit:
curve.set_symbol("o")
curve.set_pen_width(4)
curve.setDownsampling(ds=1, auto=None, method=method)
curve.setClipToView(True)
def setup_dap_for_scan(self):
"""Setup DAP updates for the new scan."""
self.bec_dispatcher.disconnect_slot(
@@ -1168,7 +1329,9 @@ class Waveform(PlotBase):
# find the device curve
parent_curve = self._find_curve_by_label(parent_label)
if parent_curve is None:
logger.warning(f"No device curve found for DAP curve '{dap_curve.name()}'!")
logger.warning(
f"No device curve found for DAP curve '{dap_curve.name()}'!"
) # TODO triggerd when DAP curve is removed from the curve dialog, why?
continue
x_data, y_data = parent_curve.get_data()
@@ -1363,9 +1526,6 @@ class Waveform(PlotBase):
default_axis = pg.AxisItem(orientation="bottom")
self.plot_item.setAxisItems({"bottom": default_axis})
if mode not in ["timestamp", "index", "auto"]:
self.x_axis_mode["entry"] = self.entry_validator.validate_signal(mode, None)
self.set_x_label_suffix(self.x_axis_mode["label_suffix"])
def _categorise_device_curves(self) -> str:
@@ -1395,7 +1555,7 @@ class Waveform(PlotBase):
# Iterate over all curves
for curve in self.curves:
if curve.config.source == "custom":
if curve.config.source != "device":
continue
dev_name = curve.config.signal.name
if dev_name in readout_priority_async:
@@ -1406,7 +1566,6 @@ class Waveform(PlotBase):
found_sync = True
else:
logger.warning("Device {dev_name} not found in readout priority list.")
# Determine the mode of the scan
if found_async and found_sync:
mode = "mixed"
@@ -1440,18 +1599,34 @@ class Waveform(PlotBase):
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is not None:
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
else:
if scan_index is None:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self._emit_signal_update()
return
if scan_index == -1:
scan_item = self.client.queue.scan_storage.current_scan
if scan_item is not None:
if scan_item.status_message is None:
logger.warning(f"Scan item with {scan_item.scan_id} has no status message.")
return
self.scan_item = scan_item
self.scan_id = scan_item.scan_id
self._emit_signal_update()
return
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
self._emit_signal_update()
def _emit_signal_update(self):
self._categorise_device_curves()
self.setup_dap_for_scan()
@@ -1574,9 +1749,11 @@ class Waveform(PlotBase):
self.clear_all()
if self.curve_settings_dialog is not None:
self.curve_settings_dialog.close()
self.curve_settings_dialog.deleteLater()
self.curve_settings_dialog = None
if self.dap_summary_dialog is not None:
self.dap_summary_dialog.close()
self.dap_summary_dialog.deleteLater()
self.dap_summary_dialog = None
super().cleanup()
@@ -1586,7 +1763,7 @@ class DemoApp(QMainWindow): # pragma: no cover
super().__init__()
self.setWindowTitle("Waveform Demo")
self.resize(800, 600)
self.main_widget = QWidget()
self.main_widget = QWidget(self)
self.layout = QHBoxLayout(self.main_widget)
self.setCentralWidget(self.main_widget)
@@ -1604,8 +1781,6 @@ class DemoApp(QMainWindow): # pragma: no cover
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
set_theme("dark")
widget = DemoApp()

View File

@@ -25,8 +25,7 @@ class BECProgressBar(BECWidget, QWidget):
ICON_NAME = "page_control"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
accent_colors = get_accent_colors()

View File

@@ -6,6 +6,7 @@ from bec_lib.endpoints import EndpointInfo, MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtGui
from qtpy.QtCore import QObject
from bec_widgets.utils import BECConnector, ConnectionConfig
@@ -77,7 +78,7 @@ class RingConfig(ProgressbarConfig):
)
class Ring(BECConnector):
class Ring(BECConnector, QObject):
USER_ACCESS = [
"_get_all_rpc",
"_rpc_id",
@@ -108,7 +109,7 @@ class Ring(BECConnector):
if isinstance(config, dict):
config = RingConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs)
self.parent_progress_widget = parent_progress_widget
self.color = None
@@ -227,9 +228,9 @@ class Ring(BECConnector):
self.config.connections.slot = None
self.config.connections.endpoint = None
elif mode == "scan":
self.set_connections("on_scan_progress", "scans/scan_progress")
self.set_connections("on_scan_progress", MessageEndpoints.scan_progress())
elif mode == "device":
self.set_connections("on_device_readback", f"internal/devices/readback/{device}")
self.set_connections("on_device_readback", MessageEndpoints.device_readback(device))
self.parent_progress_widget.enable_auto_updates(False)
@@ -243,12 +244,12 @@ class Ring(BECConnector):
"""
if self.config.connections.endpoint == endpoint and self.config.connections.slot == slot:
return
else:
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
self.bec_dispatcher.disconnect_slot(
self.config.connections.slot, self.config.connections.endpoint
)
self.config.connections = ProgressbarConnections(slot=slot, endpoint=endpoint)
self.bec_dispatcher.connect_slot(getattr(self, slot), endpoint)
def reset_connection(self):
"""

View File

@@ -71,6 +71,10 @@ class RingProgressBarConfig(ConnectionConfig):
class RingProgressBar(BECWidget, QWidget):
"""
Show the progress of devices, scans or custom values in the form of ring progress bars.
"""
PLUGIN = True
ICON_NAME = "track_changes"
USER_ACCESS = [
@@ -110,8 +114,7 @@ class RingProgressBar(BECWidget, QWidget):
if isinstance(config, dict):
config = RingProgressBarConfig(**config, widget_class=self.__class__.__name__)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)

View File

@@ -44,8 +44,9 @@ class BECQueue(BECWidget, CompactPopupWidget):
refresh_upon_start: bool = True,
**kwargs,
):
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
super().__init__(
parent=parent, layout=QVBoxLayout, client=client, gui_id=gui_id, config=config, **kwargs
)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)

View File

@@ -89,8 +89,7 @@ class BECStatusBox(BECWidget, CompactPopupWidget):
gui_id: str = None,
**kwargs,
):
super().__init__(client=client, gui_id=gui_id, **kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QHBoxLayout)
super().__init__(parent=parent, layout=QHBoxLayout, client=client, gui_id=gui_id, **kwargs)
self.box_name = box_name
self.status_container = defaultdict(lambda: {"info": None, "item": None, "widget": None})

View File

@@ -13,6 +13,10 @@ from bec_widgets.widgets.services.device_browser.device_item import DeviceItem
class DeviceBrowser(BECWidget, QWidget):
"""
DeviceBrowser is a widget that displays all available devices in the current BEC session.
"""
device_update: Signal = Signal()
PLUGIN = True
ICON_NAME = "lists"
@@ -25,8 +29,7 @@ class DeviceBrowser(BECWidget, QWidget):
gui_id: Optional[str] = None,
**kwargs,
) -> None:
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.get_bec_shortcuts()
self.ui = None

View File

@@ -8,14 +8,15 @@ import re
from collections import deque
from functools import partial, reduce
from re import Pattern
from typing import TYPE_CHECKING, Literal
from typing import Literal
from bec_lib.client import BECClient
from bec_lib.connector import ConnectorBase
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import LogLevel, bec_logger
from bec_lib.messages import LogMessage, StatusMessage
from qtpy.QtCore import QDateTime, Qt, Signal # type: ignore
from PySide6.QtCore import QObject
from qtpy.QtCore import QDateTime, Qt, Signal, SignalInstance # type: ignore
from qtpy.QtGui import QFont
from qtpy.QtWidgets import (
QApplication,
@@ -51,9 +52,6 @@ from bec_widgets.widgets.utility.logpanel._util import (
simple_color_format,
)
if TYPE_CHECKING:
from PySide6.QtCore import SignalInstance
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
@@ -68,20 +66,22 @@ DEFAULT_LOG_COLORS = {
}
class BecLogsQueue:
class BecLogsQueue(QObject):
"""Manages getting logs from BEC Redis and formatting them for display"""
new_message = Signal()
def __init__(
self,
parent: QObject | None,
conn: ConnectorBase,
new_message_signal: SignalInstance,
maxlen: int = 1000,
line_formatter: LineFormatter = noop_format,
) -> None:
super().__init__(parent=parent)
self._timestamp_start: QDateTime | None = None
self._timestamp_end: QDateTime | None = None
self._conn = conn
self._new_message_signal: SignalInstance | None = new_message_signal
self._max_length = maxlen
self._data: deque[LogMessage] = deque([], self._max_length)
self._display_queue: deque[str] = deque([], self._max_length)
@@ -91,9 +91,9 @@ class BecLogsQueue:
self._set_formatter_and_update_filter(line_formatter)
self._conn.register([MessageEndpoints.log()], None, self._process_incoming_log_msg)
def disconnect(self):
def unsub_from_redis(self):
"""Stop listening to the Redis log stream"""
self._conn.unregister([MessageEndpoints.log()], None, self._process_incoming_log_msg)
self._new_message_signal.disconnect()
def _process_incoming_log_msg(self, msg: dict):
try:
@@ -101,10 +101,9 @@ class BecLogsQueue:
self._data.append(_msg)
if self.filter is None or self.filter(_msg):
self._display_queue.append(self._line_formatter(_msg))
if self._new_message_signal:
self._new_message_signal.emit()
except Exception:
logger.warning("Error in LogPanel incoming message callback!")
self.new_message.emit()
except Exception as e:
logger.warning(f"Error in LogPanel incoming message callback: {e}")
def _set_formatter_and_update_filter(self, line_formatter: LineFormatter = noop_format):
self._line_formatter: LineFormatter = line_formatter
@@ -144,6 +143,7 @@ class BecLogsQueue:
@property
def filter(self) -> LineFilter:
"""A function which filters a log message based on all applied criteria"""
thresh = LogLevel[self._log_level].value if self._log_level is not None else 0
return self._combine_filters(
partial(level_filter, thresh=thresh),
@@ -153,6 +153,7 @@ class BecLogsQueue:
)
def update_level_filter(self, level: str):
"""Change the log-level of the level filter"""
if level not in [l.name for l in LogLevel]:
logger.error(f"Logging level {level} unrecognized for filter!")
return
@@ -160,34 +161,42 @@ class BecLogsQueue:
self._set_formatter_and_update_filter(self._line_formatter)
def update_search_filter(self, search_query: Pattern | str | None = None):
"""Change the string or regex to filter against"""
self._search_query = search_query
self._set_formatter_and_update_filter(self._line_formatter)
def update_time_filter(self, start: QDateTime | None, end: QDateTime | None):
"""Change the start and/or end times to filter against"""
self._timestamp_start = start
self._timestamp_end = end
self._set_formatter_and_update_filter(self._line_formatter)
def update_service_filter(self, services: set[str]):
"""Change the selected services to display"""
self._selected_services = services
self._set_formatter_and_update_filter(self._line_formatter)
def update_line_formatter(self, line_formatter: LineFormatter):
"""Update the formatter"""
self._set_formatter_and_update_filter(line_formatter)
def display_all(self) -> str:
"""Return formatted output for all log messages"""
return "\n".join(self._queue_formatter(self._data.copy()))
def format_new(self):
"""Return formatted output for the display queue"""
res = "\n".join(self._display_queue)
self._display_queue = deque([], self._max_length)
return res
def clear_logs(self):
"""Clear the cache and display queue"""
self._data = deque([])
self._display_queue = deque([])
def fetch_history(self):
"""Fetch all available messages from Redis"""
self._data = deque(
item["data"]
for item in self._conn.xread(
@@ -196,14 +205,16 @@ class BecLogsQueue:
)
def unique_service_names_from_history(self) -> set[str]:
"""Go through the log history to determine active service names"""
return set(msg.log_msg["service_name"] for msg in self._data)
class LogPanelToolbar(QWidget):
services_selected: pyqtBoundSignal = Signal(set)
services_selected: SignalInstance = Signal(set)
def __init__(self, parent: QWidget | None = None) -> None:
"""A toolbar for the logpanel, mainly used for managing the states of filters"""
super().__init__(parent)
# in unix time
@@ -337,6 +348,7 @@ class LogPanelToolbar(QWidget):
def service_list_update(
self, services_info: dict[str, StatusMessage], services_from_history: set[str], *_, **__
):
"""Change the list of services which can be selected"""
self._unique_service_names = set([s.split("/")[0] for s in services_info.keys()])
self._unique_service_names |= services_from_history
if self._services_selected is None:
@@ -396,10 +408,11 @@ class LogPanel(TextBox):
self._update_colors()
self._service_status = service_status or BECServiceStatusMixin(self, client=self.client) # type: ignore
self._log_manager = BecLogsQueue(
parent,
self.client.connector, # type: ignore
new_message_signal=self._new_messages,
line_formatter=partial(simple_color_format, colors=self._colors),
)
self._log_manager.new_message.connect(self._new_messages)
self.toolbar = LogPanelToolbar(parent=parent)
self.toolbar_area = QScrollArea()
@@ -513,7 +526,9 @@ class LogPanel(TextBox):
def cleanup(self):
self._service_status.cleanup()
self._log_manager.disconnect()
self._log_manager.unsub_from_redis()
self._log_manager.new_message.disconnect(self._new_messages)
self._new_messages.disconnect(self._on_append)
super().cleanup()

View File

@@ -30,10 +30,8 @@ class BECSpinBox(BECWidget, QDoubleSpinBox):
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QDoubleSpinBox.__init__(self, parent=parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
self.setObjectName("BECSpinBox")
# Make the widget as compact as possible horizontally.
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.setAlignment(Qt.AlignHCenter)

View File

@@ -9,13 +9,11 @@ from bec_widgets.utils.bec_widget import BECWidget
class BECColorMapWidget(BECWidget, QWidget):
colormap_changed_signal = Signal(str)
ICON_NAME = "palette"
USER_ACCESS = ["colormap"]
PLUGIN = True
RPC = False
def __init__(self, parent=None, cmap: str = "magma", **kwargs):
super().__init__(**kwargs)
QWidget.__init__(self, parent=parent)
super().__init__(parent=parent, **kwargs)
# Create the ColorMapButton
self.button = ColorMapButton()

View File

@@ -9,10 +9,10 @@ from bec_widgets.utils.colors import set_theme
class DarkModeButton(BECWidget, QWidget):
USER_ACCESS = ["toggle_dark_mode"]
ICON_NAME = "dark_mode"
PLUGIN = True
RPC = False
def __init__(
self,
@@ -22,8 +22,7 @@ class DarkModeButton(BECWidget, QWidget):
toolbar: bool = False,
**kwargs,
) -> None:
super().__init__(client=client, gui_id=gui_id, theme_update=True, **kwargs)
QWidget.__init__(self, parent)
super().__init__(parent=parent, client=client, gui_id=gui_id, theme_update=True, **kwargs)
self._dark_mode_enabled = False
self.layout = QHBoxLayout(self)
@@ -99,9 +98,6 @@ class DarkModeButton(BECWidget, QWidget):
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")

View File

@@ -0,0 +1,58 @@
(user.plugin_widgets)=
# PLugin repository widgets
## Adding widgets to the plugin repository
Widgets can be created by users and added to a beamline plugin repository, then they can be used in
all the same ways as built-in widgets. To make this work, the widget author should follow a few
simple guidelines.
Widgets should be added in `plugin_repo.bec_widgets.widgets`. They may be added in submodules. If
so, please make sure that these are properly defined python submodules with `__init__.py` files, so
that the widgets are discoverable.
### Preparing a widget to be a plugin
- make sure that the widget class inherits from both `BECWidget` as well as `QWidget` or a subclass
of it, such as `QComboBox` or `QLineEdit`.
- make sure it initialises each of these superclasses in its `__init__()` method, and passes the
`parent` keyword argumment on to `QWidget.__init__()`.
- add `PLUGIN = True` as a class variable to the widget class
- add `USER_ACCESS = [...]`, including any methods and properties which should be accessible in the
client to the list, as strings.
(Search the `bec_widgets` code for one of the above names for examples of these magic variables)
### Example / template
```Python
class TestWidget(BECWidget, QWidget):
USER_ACCESS = ["set_text"]
PLUGIN = True
def __init__(self, parent=None, **kwargs):
super().__init__(**kwargs)
QWidget.__init__(self, parent=parent)
self.setLayout(QHBoxLayout())
self._text_widget = QLabel("Test widget text")
self.layout().addWidget(self._text_widget)
def set_text(self, value: str):
self._text_widget.setText(value)
```
### Generating the plugin files and RPC client template
To allow the BEC client to communicate with the GUI server and to know which widgets are available,
as well as to allow the Qt Designer to find the available widgets, a code generation tool should be
run to prepare a client file which lists all the available widget classes and functions. Make sure
you are in the BEC python environment where your plugin repository is also installed, and run:
```bash
$ bw-generate-cli --target plugin_repo
```
replacing `plugin_repo` with the name of your repository. This will overwrite the file for
`plugin_repo.bec_widgets.client`. This file should not be edited by hand, and should always be
regenerated when changes are made to widgets in the plugin repository. BEC will need to be restarted
for changes made here to take effect.

View File

@@ -14,7 +14,7 @@ classifiers = [
]
dependencies = [
"bec_ipython_client>=2.21.4, <=4.0", # needed for jupyter console
"bec_lib>=2.21.4, <=4.0",
"bec_lib>=3.28.1, <=4.0",
"bec_qthemes~=0.7, >=0.7",
"black~=24.0", # needed for bw-generate-cli
"isort~=5.13, >=5.13.2", # needed for bw-generate-cli

View File

@@ -1,6 +1,6 @@
import pytest
from bec_widgets.cli import Image, MotorMap, Waveform
from bec_widgets.cli.client import Image, MotorMap, Waveform
from bec_widgets.cli.rpc.rpc_base import RPCReference
# pylint: disable=unused-argument
@@ -144,7 +144,7 @@ def test_ring_bar(qtbot, connected_client_gui_obj):
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
gui = connected_client_gui_obj
assert len(gui.windows) == 1
qtbot.waitUntil(lambda: len(gui.windows) == 1, timeout=3000)
assert gui.windows["bec"] is gui.bec
mw = gui.bec
assert mw.__class__.__name__ == "RPCReference"
@@ -155,22 +155,6 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
assert gui._ipython_registry[xw._gui_id].__class__.__name__ == "BECDockArea"
assert len(gui.windows) == 2
gui_info = gui._dump()
mw_info = gui_info[mw._gui_id]
assert mw_info["title"] == "BEC"
assert mw_info["visible"]
xw_info = gui_info[xw._gui_id]
assert xw_info["title"] == "BEC - X"
assert xw_info["visible"]
gui.hide()
gui_info = gui._dump() #
assert not any(windows["visible"] for windows in gui_info.values())
gui.show()
gui_info = gui._dump()
assert all(windows["visible"] for windows in gui_info.values())
assert gui._gui_is_alive()
gui.kill_server()
assert not gui._gui_is_alive()
@@ -186,10 +170,8 @@ def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
qtbot.waitUntil(wait_for_gui_started, timeout=3000)
# gui.windows should have bec with gui_id 'bec'
assert len(gui.windows) == 1
assert gui.windows["bec"]._gui_id == mw._gui_id
# communication should work, main dock area should have same id and be visible
gui_info = gui._dump()
assert gui_info[mw._gui_id]["visible"]
yw = gui.new("Y")
assert len(gui.windows) == 2

View File

@@ -60,18 +60,14 @@ def test_rpc_plotting_shortcuts_init_configs(qtbot, connected_client_gui_obj):
# check if the correct devices are set
# Curve
assert c1._config["signal"] == {
assert c1._config_dict["signal"] == {
"dap": None,
"name": "bpm4i",
"entry": "bpm4i",
"dap_oversample": 1,
}
assert c1._config["source"] == "device"
assert c1._config["label"] == "bpm4i-bpm4i"
# Image Item
assert im_item._config["monitor"] == "eiger"
assert im_item._config["source"] == "auto"
assert c1._config_dict["source"] == "device"
assert c1._config_dict["label"] == "bpm4i-bpm4i"
def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj):
@@ -93,6 +89,7 @@ def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj):
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
# FIXME if this gets flaky, we wait for status.scan.scan_id to be in client.history[-1] and then fetch data from history
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
@@ -113,6 +110,51 @@ def test_rpc_waveform_scan(qtbot, bec_client_lib, connected_client_gui_obj):
assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
def test_async_plotting(qtbot, bec_client_lib, connected_client_gui_obj):
gui = connected_client_gui_obj
dock = gui.bec
client = bec_client_lib
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
# Test add
dev.waveform.sim.select_model("GaussianModel")
dev.waveform.sim.params = {"amplitude": 1000, "center": 4000, "sigma": 300}
dev.waveform.async_update.put("add")
dev.waveform.waveform_shape.put(10000)
wf = dock.new("wf_dock").new("Waveform")
curve = wf.plot(y_name="waveform")
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
# Wait for the scan to finish and the data to be available in history
# Wait until scan_id is in history
def _wait_for_scan_in_history():
if len(client.history) == 0:
return False
# Once items appear in storage, the last one hast to be the one we just scanned
return client.history[-1].metadata.bec["scan_id"] == status.scan.scan_id
qtbot.waitUntil(_wait_for_scan_in_history, timeout=10000)
last_scan_data = client.history[-1]
# check plotted data
x_data, y_data = curve.get_data()
assert np.array_equal(x_data, np.linspace(0, len(y_data) - 1, len(y_data)))
assert np.array_equal(
y_data, last_scan_data.devices.waveform.get("waveform_waveform", {}).read().get("value", [])
)
# Check displayed data
x_data_display, y_data_display = curve._get_displayed_data()
# Should be not more than 1% difference, actually be closer but this might be flaky
assert np.isclose(x_data_display[-1], x_data[-1], rtol=0.01)
# Downsampled data should be smaller than original data
assert len(y_data_display) < len(y_data)
def test_rpc_image(qtbot, bec_client_lib, connected_client_gui_obj):
gui = connected_client_gui_obj
dock = gui.bec

View File

@@ -27,9 +27,10 @@ def qapplication(qtbot, request, testable_qtimer_class): # pylint: disable=unus
if request.node.stash._storage.get("failed"):
print("Test failed, skipping cleanup checks")
return
bec_dispatcher = bec_dispatcher_module.BECDispatcher()
bec_dispatcher.stop_cli_server()
testable_qtimer_class.check_all_stopped(qtbot)
qapp = QApplication.instance()
qapp.processEvents()
if hasattr(qapp, "os_listener") and qapp.os_listener:
@@ -53,6 +54,8 @@ def bec_dispatcher(threads_check): # pylint: disable=unused-argument
bec_dispatcher.disconnect_all()
# clean BEC client
bec_dispatcher.client.shutdown()
# stop the cli server
bec_dispatcher.stop_cli_server()
# reinitialize singleton for next test
bec_dispatcher_module.BECDispatcher.reset_singleton()

View File

@@ -29,8 +29,6 @@ def test_axis_settings_init(axis_settings_fixture):
assert axis_settings.layout.count() == 1 # scroll area
# Check the target
assert axis_settings.target_widget == plot_base
# Check the object name
assert axis_settings.objectName() == "AxisSettings"
def test_change_ui_updates_plot_base(axis_settings_fixture, qtbot):

Some files were not shown because too many files have changed in this diff Show More