1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-14 12:40:54 +02:00

Compare commits

..

2 Commits

212 changed files with 16785 additions and 16687 deletions

View File

@@ -197,13 +197,7 @@ end-2-end-conda:
script:
- *clone-repos
- *install-os-packages
- conda config --show-sources
- conda config --add channels conda-forge
- conda config --system --remove channels https://repo.anaconda.com/pkgs/main
- conda config --system --remove channels https://repo.anaconda.com/pkgs/r
- conda config --remove channels https://repo.anaconda.com/pkgs/main
- conda config --remove channels https://repo.anaconda.com/pkgs/r
- conda config --show-sources
- conda config --prepend channels conda-forge
- conda config --set channel_priority strict
- conda config --set always_yes yes --set changeps1 no
- conda create -q -n test-environment python=3.11
@@ -217,7 +211,8 @@ end-2-end-conda:
- pip install -e ./ophyd_devices
- pip install -e .[dev,pyside6]
- pytest -v --files-path ./ --start-servers --flush-redis --random-order ./tests/end-2-end
- cd ./tests/end-2-end
- pytest -v --start-servers --flush-redis --random-order
artifacts:
when: on_failure
@@ -232,7 +227,6 @@ end-2-end-conda:
- if: '$CI_PIPELINE_SOURCE == "parent_pipeline"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "production"'
- if: '$CI_MERGE_REQUEST_TARGET_BRANCH_NAME =~ /^pre_release.*$/'
semver:
stage: Deploy

View File

@@ -1,25 +1,6 @@
# CHANGELOG
## v1.25.1 (2025-03-24)
### Bug Fixes
- **positioner_box**: Fixed motor moving flags for spinner
([`be274a1`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/be274a10fc76528e1e5d6b309678c7fb4e9b890e))
- **positioner_box**: If possible tweak should use the current setpoint instead of the readback
([`e5c9dd2`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/e5c9dd288c571d29722497a2d40b000d1cffb475))
### Continuous Integration
- Add e2e job for pre_release branches
([`d86ef4e`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/d86ef4e763b321b1c82be71c9f275abb610fed06))
- Fix conda channels for PSI policy change
([`6cf39b3`](https://gitlab.psi.ch/bec/bec_widgets/-/commit/6cf39b3796f850294705465adfaf6ad25a71461f))
## v1.25.0 (2025-03-07)
### Features

View File

@@ -13,10 +13,10 @@ from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeSlot as Slot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.device_control.positioner_group.positioner_group import (
PositionerGroup,
@@ -29,7 +29,6 @@ MODULE_PATH = os.path.dirname(bec_widgets.__file__)
logger = bec_logger.logger
# FIXME BECWaveFormWidget is gone, this app will not work until adapted to new Waveform
class Alignment1D:
"""Alignment GUI to perform 1D scans"""

View File

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

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>300</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QPushButton" name="open_dock_area">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QPushButton" name="open_auto_update_dock_area">
<property name="text">
<string>PushButton</string>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@@ -1,105 +0,0 @@
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QSizePolicy
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
if TYPE_CHECKING: # pragma: no cover
from qtpy.QtWidgets import QWidget
class LaunchWindow(BECMainWindow):
RPC = True
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()
self.resize(500, 300)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
ui_file_path = os.path.join(MODULE_PATH, "applications/launch_dialog.ui")
self.load_ui(ui_file_path)
self.ui.open_dock_area.setText("Open Dock Area")
self.ui.open_dock_area.clicked.connect(lambda: self.launch("dock_area"))
self.ui.open_auto_update_dock_area.setText("Open Dock Area with Auto Update")
self.ui.open_auto_update_dock_area.clicked.connect(
lambda: self.launch("auto_update_dock_area", "auto_updates")
)
def launch(
self,
launch_script: str,
name: str | None = None,
geometry: tuple[int, int, int, int] | None = None,
) -> 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)}.")
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}")
logger.info(f"Existing dock areas: {geometry}")
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 show_launcher(self):
self.show()
def hide_launcher(self):
self.hide()
def cleanup(self):
super().close()

View File

@@ -1,127 +1,88 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import threading
from queue import Queue
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.messages import ScanStatusMessage
from pydantic import BaseModel
from bec_widgets.utils.error_popups import SafeSlot
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.containers.dock.dock_area import BECDockArea
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
if TYPE_CHECKING:
from .client import BECDockArea, BECFigure
logger = bec_logger.logger
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:
_default_dock: BECDock
create_default_dock: bool = False
enabled: bool = False
dock_name: str = None
def __init__(self, dock_area: BECDockArea):
self.dock_area = dock_area
self.bec_dispatcher = dock_area.bec_dispatcher
self._default_dock = None # type:ignore
self.current_widget: BECWidget | None = None
self.dock_name = None
self._enabled = False
def connect(self):
"""
Establish all connections for the auto updates.
"""
self.bec_dispatcher.connect_slot(self._on_scan_status, MessageEndpoints.scan_status())
def disconnect(self):
"""
Disconnect all connections for the auto updates.
"""
self.bec_dispatcher.disconnect_slot(
self._on_scan_status, MessageEndpoints.scan_status() # type:ignore
)
@SafeSlot()
def _on_scan_status(self, content: dict, metadata: dict) -> None:
"""
Callback for scan status messages.
"""
msg = ScanStatusMessage(**content, metadata=metadata)
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 __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 = "update_dock"
self._default_dock = self.dock_area.new(self.dock_name)
self.current_widget = self._default_dock.new("Waveform")
self.dock_name = "default_figure"
self._default_dock = self.gui.add_dock(self.dock_name)
self._default_dock.add_widget("BECFigure")
self._default_fig = self._default_dock.widget_list[0]
@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:
@staticmethod
def get_scan_info(msg) -> ScanInfo:
"""
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.
Update the script with the given data.
"""
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
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,
)
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_default_figure(self) -> BECFigure | None:
"""
Get the default figure from the GUI.
"""
return self._default_fig
def get_selected_device(
self, monitored_devices, selected_device: str | None = None
) -> str | None:
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 is None:
selected_device = self.dock_area.selected_device
if selected_device:
return selected_device
if len(monitored_devices) > 0:
@@ -129,57 +90,31 @@ class AutoUpdates:
return sel_device
return None
@property
def enabled(self) -> bool:
def handler(self, info: ScanInfo) -> None:
"""
Get the enabled status of the auto updates.
Default update function.
"""
return self._enabled
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)
@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.connect()
self.on_start()
else:
self.disconnect()
self.on_stop()
########################################################################
################# Update Functions #####################################
########################################################################
def simple_line_scan(self, info: ScanStatusMessage) -> None:
def simple_line_scan(self, info: ScanInfo) -> 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
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
# 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(
yield fig.clear_all()
yield fig.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
@@ -188,56 +123,42 @@ class AutoUpdates:
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:
def simple_grid_scan(self, info: ScanInfo) -> 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
# 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}"
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,
)
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:
def best_effort(self, info: ScanInfo) -> 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:
fig = self.get_default_figure()
if not fig:
return
dev_x = info.scan_report_devices[0] # type:ignore
dev_y = self.get_selected_device(info.readout_priority["monitored"]) # type:ignore
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
# 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(
yield fig.clear_all()
yield fig.plot(
x_name=dev_x,
y_name=dev_y,
label=f"Scan {info.scan_number} - {dev_y}",
@@ -245,51 +166,3 @@ class AutoUpdates:
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.
"""

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,40 @@
"""Client utilities for the BEC GUI."""
from __future__ import annotations
import importlib
import importlib.metadata as imd
import json
import os
import select
import subprocess
import threading
import time
from contextlib import contextmanager
from threading import Lock
from typing import TYPE_CHECKING, Literal, TypeAlias, cast
from dataclasses import dataclass
from typing import TYPE_CHECKING
import msgpack
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import lazy_import_from
from rich.console import Console
from rich.table import Table
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from cryptography.fernet import Fernet
import bec_widgets.cli.client as client
from bec_widgets.cli.rpc.rpc_base import RPCBase, RPCReference
from bec_widgets.cli.auto_updates import AutoUpdates
from bec_widgets.cli.rpc.rpc_base import RPCBase
if TYPE_CHECKING: # pragma: no cover
from bec_lib.messages import GUIRegistryStateMessage
if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_lib.device import DeviceBase
from bec_widgets.utils.bec_dispatcher import BECDispatcher
else:
GUIRegistryStateMessage = lazy_import_from("bec_lib.messages", "GUIRegistryStateMessage")
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
BECDispatcher = lazy_import_from("bec_widgets.utils.bec_dispatcher", ("BECDispatcher",))
logger = bec_logger.logger
IGNORE_WIDGETS = ["LaunchWindow"]
RegistryState: TypeAlias = dict[
Literal["gui_id", "name", "widget_class", "config", "__rpc__"], str | bool | dict
]
# pylint: disable=redefined-outer-scope
def _filter_output(output: str) -> str:
"""
@@ -72,11 +71,12 @@ def _get_output(process, logger) -> None:
def _start_plot_process(
gui_id: str,
gui_class_id: str,
gui_class: type,
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]:
acl_data: bytes | None = None,
token: bytes | None = None,
logger=None,
) -> None:
"""
Start the plot in a new process.
@@ -85,16 +85,7 @@ def _start_plot_process(
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = [
"bec-gui-server",
"--id",
gui_id,
"--gui_class",
gui_class,
"--gui_class_id",
gui_class_id,
"--hide",
]
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
if config:
if isinstance(config, dict):
config = json.dumps(config)
@@ -102,6 +93,8 @@ def _start_plot_process(
env_dict = os.environ.copy()
env_dict["PYTHONUNBUFFERED"] = "1"
env_dict["BEC_GUI_ACL"] = acl_data or b""
env_dict["BEC_GUI_TOKEN"] = token or b""
if logger is None:
stdout_redirect = subprocess.DEVNULL
@@ -129,20 +122,16 @@ def _start_plot_process(
class RepeatTimer(threading.Timer):
"""RepeatTimer class."""
def run(self):
while not self.finished.wait(self.interval):
self.function(*self.args, **self.kwargs)
# pylint: disable=protected-access
@contextmanager
def wait_for_server(client: BECGuiClient):
"""Context manager to wait for the server to start."""
def wait_for_server(client):
timeout = client._startup_timeout
if not timeout:
if client._gui_is_alive():
if client.gui_is_alive():
# there is hope, let's wait a bit
timeout = 1
else:
@@ -160,168 +149,233 @@ def wait_for_server(client: BECGuiClient):
yield
class WidgetNameSpace:
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr, value in self.__dict__.items():
docs = value.__doc__
docs = docs if docs else "No description available"
table.add_row(attr, docs)
console.print(table)
return ""
### ----------------------------
### NOTE
### it is far easier to extend the 'delete' method on the client side,
### to know when the client is deleted, rather than listening to server
### to get notified. However, 'generate_cli.py' cannot add extra stuff
### in the generated client module. So, here a class with the same name
### is created, and client module is patched.
class BECDockArea(client.BECDockArea):
def delete(self):
if self is BECGuiClient._top_level["main"].widget:
raise RuntimeError("Cannot delete main window")
super().delete()
try:
del BECGuiClient._top_level[self._gui_id]
except KeyError:
# if a dock area is not at top level
pass
class AvailableWidgetsNamespace:
"""Namespace for available widgets in the BEC GUI."""
client.BECDockArea = BECDockArea
### ----------------------------
def __init__(self):
for widget in client.Widgets:
name = widget.value
if name in IGNORE_WIDGETS:
continue
setattr(self, name, name)
def __repr__(self):
console = Console()
table = Table(title="Available widgets for BEC CLI usage")
table.add_column("Widget Name", justify="left", style="magenta")
table.add_column("Description", justify="left")
for attr_name, _ in self.__dict__.items():
docs = getattr(client, attr_name).__doc__
docs = docs if docs else "No description available"
table.add_row(attr_name, docs if len(docs.strip()) > 0 else "No description available")
console.print(table)
return ""
@dataclass
class WidgetDesc:
title: str
widget: BECDockArea
class BECGuiClient(RPCBase):
"""BEC GUI client class. Container for GUI applications within Python."""
_top_level = {}
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._lock = Lock()
self._anchor_widget = "launcher"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
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._server_registry: dict[str, RegistryState] = {}
self._ipython_registry: dict[str, RPCReference] = {}
self.available_widgets = AvailableWidgetsNamespace()
####################
#### Client API ####
####################
self._fernet = None
@property
def launcher(self) -> RPCBase:
"""The launcher object."""
return RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self, object_name="launcher")
def windows(self):
return self._top_level
def connect_to_gui_server(self, gui_id: str) -> None:
"""Connect to a GUI server"""
# Unregister the old callback
self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
@property
def auto_updates(self):
if self._auto_updates_enabled:
with wait_for_server(self):
return self._auto_updates
def _get_update_script(self) -> AutoUpdates | None:
eps = imd.entry_points(group="bec.widgets.auto_updates")
for ep in eps:
if ep.name == "plugin_widgets_update":
try:
spec = importlib.util.find_spec(ep.module)
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self._top_level["main"].widget)
except Exception as e:
logger.error(f"Error loading auto update script from plugin: {str(e)}")
return None
@property
def selected_device(self):
"""
Selected device for the plot.
"""
auto_update_config_ep = MessageEndpoints.gui_auto_update_config(self._gui_id)
auto_update_config = self._client.connector.get(auto_update_config_ep)
if auto_update_config:
return auto_update_config.selected_device
return None
@selected_device.setter
def selected_device(self, device: str | DeviceBase):
if isinstance_based_on_class_name(device, "bec_lib.device.DeviceBase"):
self._client.connector.set_and_publish(
MessageEndpoints.gui_auto_update_config(self._gui_id),
messages.GUIAutoUpdateConfigMessage(selected_device=device.name),
)
elif isinstance(device, str):
self._client.connector.set_and_publish(
MessageEndpoints.gui_auto_update_config(self._gui_id),
messages.GUIAutoUpdateConfigMessage(selected_device=device),
)
else:
raise ValueError("Device must be a string or a device object")
def _start_update_script(self) -> None:
self._client.connector.register(MessageEndpoints.scan_status(), cb=self._handle_msg_update)
def _handle_msg_update(self, msg: MessageObject) -> None:
if self.auto_updates is not None:
# pylint: disable=protected-access
return self._update_script_msg_parser(msg.value)
def _update_script_msg_parser(self, msg: messages.BECMessage) -> None:
if isinstance(msg, messages.ScanStatusMessage):
if not self.gui_is_alive():
return
if self._auto_updates_enabled:
return self.auto_updates.do_update(msg)
def _gui_post_startup(self):
self._top_level["main"] = WidgetDesc(
title="BEC Widgets", widget=BECDockArea(gui_id=self._gui_id)
)
self._gui_id = gui_id
if self._auto_updates_enabled:
if self._auto_updates is None:
auto_updates = self._get_update_script()
if auto_updates is None:
AutoUpdates.create_default_dock = True
AutoUpdates.enabled = True
auto_updates = AutoUpdates(self._top_level["main"].widget)
if auto_updates.create_default_dock:
auto_updates.start_default_dock()
self._start_update_script()
self._auto_updates = auto_updates
self._do_show_all()
self._gui_started_event.set()
# reset the namespace
self._update_dynamic_namespace({})
self._server_registry = {}
self._top_level = {}
self._ipython_registry = {}
# def _update_gui_acls(self, username: str, password: str | None):
# self._client.connector.send(
# MessageEndpoints.gui_acls(self._gui_id),
# messages.CredentialsMessage(
# credentials={
# "token": self._fernet.encrypt(
# msgpack.dumps({"username": username, "password": password})
# )
# }
# ),
# )
# Register the new callback
self._client.connector.register(
MessageEndpoints.gui_registry_state(self._gui_id),
cb=self._handle_registry_update,
parent=self,
from_start=True,
)
def start_server(self, wait=False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
encr_token = Fernet.generate_key()
self._fernet = Fernet(encr_token)
conn = self._client.connector._redis_conn.connection_pool.connection_kwargs
acl_data = {"username": conn.get("username"), "password": conn.get("password")}
acl_data = self._fernet.encrypt(msgpack.dumps(acl_data))
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
self.__class__,
self._client._service_config.config,
acl_data=acl_data,
token=encr_token,
logger=logger,
)
@property
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return {widget.object_name: widget for widget in self._top_level.values()}
def gui_started_callback(callback):
try:
if callable(callback):
callback()
finally:
threading.current_thread().cancel()
@property
def window_list(self) -> list:
"""List with dock areas in the GUI."""
return list(self._top_level.values())
self._gui_started_timer = RepeatTimer(
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
def start(self, wait: bool = False) -> None:
"""Start the GUI server."""
return self._start(wait=wait)
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):
return self.start_server()
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
for window in self._top_level.values():
window.widget.show()
def show_all(self):
with wait_for_server(self):
return self._do_show_all()
def hide_all(self):
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("hide")
for window in self._top_level.values():
window.widget.hide()
def show(self):
"""Show the GUI window."""
if self._check_if_server_is_alive():
return self._show_all()
return self.start(wait=True)
if self._process is not None:
return self.show_all()
# backward compatibility: show() was also starting server
return self.start_server(wait=True)
def hide(self):
"""Hide the GUI window."""
return self._hide_all()
return self.hide_all()
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
) -> client.BECDockArea:
"""Create a new top-level dock area.
@property
def main(self):
"""Return client to main dock area (in main window)"""
with wait_for_server(self):
return self._top_level["main"].widget
Args:
name(str, optional): The name of the dock area. Defaults to None.
wait(bool, optional): Whether to wait for the server to start. Defaults to True.
geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h)
Returns:
client.BECDockArea: The new dock area.
def new(self, title):
"""Ask main window to create a new top-level dock area"""
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", title)
self._top_level[widget._gui_id] = WidgetDesc(title=title, widget=widget)
return widget
def close(self) -> None:
"""
if not self._check_if_server_is_alive():
self.start(wait=True)
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
widget = rpc_client._run_rpc(
"launch", "dock_area", name, geometry
) # pylint: disable=protected-access
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:launcher", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
return widget
def delete(self, name: str) -> None:
"""Delete a dock area.
Args:
name(str): The name of the dock area.
Close the gui window.
"""
widget = self.windows.get(name)
if widget is None:
raise ValueError(f"Dock area {name} not found.")
widget._run_rpc("close") # pylint: disable=protected-access
def delete_all(self) -> None:
"""Delete all dock areas."""
for widget_name in self.windows:
self.delete(widget_name)
def kill_server(self) -> None:
"""Kill the GUI server."""
# Unregister the registry state
self._killed = True
self._top_level.clear()
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
@@ -337,194 +391,3 @@ class BECGuiClient(RPCBase):
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
# Unregister the registry state
self._client.connector.unregister(
MessageEndpoints.gui_registry_state(self._gui_id), cb=self._handle_registry_update
)
# Remove all reference from top level
self._top_level.clear()
self._server_registry.clear()
def close(self):
"""Deprecated. Use kill_server() instead."""
# FIXME, deprecated in favor of kill, will be removed in the future
self.kill_server()
#########################
#### Private methods ####
#########################
def _check_if_server_is_alive(self):
"""Checks if the process is alive"""
if self._process is None:
return False
if self._process.poll() is not None:
return False
return True
def _gui_post_startup(self):
timeout = 60
# 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())) < 2 or not hasattr(
self, self._anchor_widget
):
time.sleep(0.1)
else:
break
self._gui_started_event.set()
def _start_server(self, wait: bool = False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
if self._process is None or self._process.poll() is not None:
logger.success("GUI starting...")
self._startup_timeout = 5
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id,
gui_class_id="bec", # FIXME me experiment
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
def gui_started_callback(callback):
try:
if callable(callback):
callback()
finally:
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)
)
self._gui_started_timer.start()
if wait:
self._gui_started_event.wait()
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,
parent=self,
)
return self._start_server(wait=wait)
@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 = 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}:launcher", parent=self)
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.show()
def _show_all(self):
with wait_for_server(self):
return self._do_show_all()
def _hide_all(self):
with wait_for_server(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, 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.
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
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)
removed_widgets = [
widget.object_name for widget in self._top_level.values() if widget._is_deleted()
]
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)
for gui_id, widget_ref in top_level_widgets.items():
setattr(self, widget_ref.object_name, widget_ref)
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.
"""
object_name = state["object_name"]
gui_id = state["gui_id"]
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, object_name=object_name, parent=parent)
self._ipython_registry[gui_id] = widget
else:
widget = obj
obj = RPCReference(registry=self._ipython_registry, gui_id=gui_id)
return obj
if __name__ == "__main__": # pragma: no cover
from bec_lib.client import BECClient
from bec_lib.service_config import ServiceConfig
try:
config = ServiceConfig()
bec_client = BECClient(config)
bec_client.start()
# Test the client_utils.py module
gui = BECGuiClient()
gui.start(wait=True)
gui.new().new(widget="Waveform")
time.sleep(10)
finally:
gui.kill_server()

View File

@@ -30,8 +30,7 @@ else:
class ClientGenerator:
def __init__(self):
self.header = """# This file was automatically generated by generate_cli.py
# type: ignore \n
self.header = """# This file was automatically generated by generate_cli.py\n
from __future__ import annotations
import enum
from typing import Literal, Optional, overload
@@ -96,21 +95,9 @@ class {class_name}(RPCBase):"""
self.content += f"""
class {class_name}(RPCBase):"""
if cls.__doc__:
# We only want the first line of the docstring
# But skip the first line if it's a blank line
first_line = cls.__doc__.split("\n")[0]
if first_line:
class_docs = first_line
else:
class_docs = cls.__doc__.split("\n")[1]
self.content += f"""
\"\"\"{class_docs}\"\"\"
"""
if not cls.USER_ACCESS:
self.content += """...
"""
for method in cls.USER_ACCESS:
is_property_setter = False
obj = getattr(cls, method, None)

View File

@@ -1,10 +1,9 @@
from __future__ import annotations
import inspect
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
@@ -12,19 +11,14 @@ 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
if TYPE_CHECKING:
from bec_lib import messages
from bec_lib.connector import MessageObject
from bec_widgets.cli.client_utils import BECGuiClient
else:
messages = lazy_import("bec_lib.messages")
# from bec_lib.connector import MessageObject
MessageObject = lazy_import_from("bec_lib.connector", ("MessageObject",))
# pylint: disable=protected-access
def rpc_call(func):
"""
@@ -41,14 +35,6 @@ 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 # type: ignore
while caller_frame:
if "jedi" in caller_frame.f_globals:
# Jedi module is present, likely tab completion
# Do not run the RPC call
return None # func(*args, **kwargs)
caller_frame = caller_frame.f_back
out = []
for arg in args:
if hasattr(arg, "name"):
@@ -58,7 +44,7 @@ def rpc_call(func):
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self._root._gui_is_alive():
if not self.gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -74,99 +60,24 @@ class RPCResponseTimeoutError(Exception):
)
class DeletedWidgetError(Exception): ...
def check_for_deleted_widget(func):
@wraps(func)
def wrapper(self, *args, **kwargs):
if self._gui_id not in self._registry:
raise DeletedWidgetError(f"Widget with gui_id {self._gui_id} has been deleted")
return func(self, *args, **kwargs)
return wrapper
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", "_is_deleted", "_name", "object_name"]:
return super().__getattribute__(name)
return self._registry[self._gui_id].__getattribute__(name)
def __setattr__(self, name, value):
if name in ["_registry", "_gui_id", "_is_deleted", "_name", "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)
@check_for_deleted_widget
def __getitem__(self, key):
return self._registry[self._gui_id].__getitem__(key)
def __repr__(self):
if self._gui_id not in self._registry:
return f"<Deleted widget with gui_id {self._gui_id}>"
return self._registry[self._gui_id].__repr__()
def __str__(self):
if self._gui_id not in self._registry:
return f"<Deleted widget with gui_id {self._gui_id}>"
return self._registry[self._gui_id].__str__()
def __dir__(self):
if self._gui_id not in self._registry:
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,
object_name: str | None = None,
parent=None,
) -> None:
def __init__(self, gui_id: str = None, config: dict = 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.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
super().__init__()
self._rpc_references: dict[str, str] = {}
# print(f"RPCBase: {self._gui_id}")
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
self._run_rpc("remove")
return f"<{qualname} object at {hex(id(self))}>"
@property
def widget_name(self):
"""
Get the widget name.
"""
return self.object_name
@property
def _root(self) -> BECGuiClient:
def _root(self):
"""
Get the root widget. This is the BECFigure widget that holds
the anchor gui_id.
@@ -175,9 +86,9 @@ class RPCBase:
# pylint: disable=protected-access
while parent._parent is not None:
parent = parent._parent
return parent # type: ignore
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=300, **kwargs) -> Any:
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
"""
Run the RPC call.
@@ -196,6 +107,7 @@ class RPCBase:
parameter={"args": args, "kwargs": kwargs, "gui_id": self._gui_id},
metadata={"request_id": request_id},
)
# pylint: disable=protected-access
receiver = self._root._gui_id
if wait_for_rpc_response:
@@ -219,11 +131,7 @@ class RPCBase:
self._client.connector.unregister(
MessageEndpoints.gui_instruction_response(request_id), cb=self._on_rpc_response
)
# we can assume that the response is a RequestResponseMessage, updated by
# the _on_rpc_response method
assert isinstance(self._rpc_response, messages.RequestResponseMessage)
# get class name
if not self._rpc_response.accepted:
raise ValueError(self._rpc_response.message["error"])
msg_result = self._rpc_response.message.get("result")
@@ -231,8 +139,8 @@ class RPCBase:
return self._create_widget_from_msg_result(msg_result)
@staticmethod
def _on_rpc_response(msg_obj: MessageObject, parent: RPCBase) -> None:
msg = cast(messages.RequestResponseMessage, msg_obj.value)
def _on_rpc_response(msg: MessageObject, parent: RPCBase) -> None:
msg = msg.value
parent._msg_wait_event.set()
parent._rpc_response = msg
@@ -253,21 +161,11 @@ class RPCBase:
return msg_result
cls = getattr(client, cls)
# The namespace of the object will be updated dynamically on the client side
# 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
# print(msg_result)
return cls(parent=self, **msg_result)
return msg_result
def _gui_is_alive(self):
def gui_is_alive(self):
"""
Check if the GUI is alive.
"""
@@ -277,27 +175,3 @@ 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,36 +1,10 @@
from __future__ import annotations
from functools import wraps
from threading import RLock
from typing import TYPE_CHECKING, Callable
from threading import Lock
from weakref import WeakValueDictionary
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
def broadcast_update(func):
"""
Decorator to broadcast updates to the RPCRegister whenever a new RPC object is added or removed.
If class attribute _skip_broadcast is set to True, the broadcast will be skipped
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
result = func(self, *args, **kwargs)
self.broadcast()
return result
return wrapper
class RPCRegister:
"""
@@ -39,6 +13,7 @@ class RPCRegister:
_instance = None
_initialized = False
_lock = Lock()
def __new__(cls, *args, **kwargs):
if cls._instance is None:
@@ -50,21 +25,8 @@ class RPCRegister:
if self._initialized:
return
self._rpc_register = WeakValueDictionary()
self._broadcast_on_hold = RPCRegisterBroadcast(self)
self._lock = RLock()
self._skip_broadcast = False
self._initialized = True
self.callbacks = []
@classmethod
def delayed_broadcast(cls):
"""
Delay the broadcast of the update to all the callbacks.
"""
register = cls()
return register._broadcast_on_hold
@broadcast_update
def add_rpc(self, rpc: QObject):
"""
Add an RPC object to the register.
@@ -76,8 +38,7 @@ class RPCRegister:
raise ValueError("RPC object must have a 'gui_id' attribute.")
self._rpc_register[rpc.gui_id] = rpc
@broadcast_update
def remove_rpc(self, rpc: BECConnector):
def remove_rpc(self, rpc: str):
"""
Remove an RPC object from the register.
@@ -88,7 +49,7 @@ class RPCRegister:
raise ValueError(f"RPC object {rpc} must have a 'gui_id' attribute.")
self._rpc_register.pop(rpc.gui_id, None)
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
def get_rpc_by_id(self, gui_id: str) -> QObject:
"""
Get an RPC object by its ID.
@@ -96,7 +57,7 @@ class RPCRegister:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject | None: The RPC object with the given ID or None
QObject: The RPC object with the given ID.
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
@@ -112,40 +73,6 @@ class RPCRegister:
connections = dict(self._rpc_register)
return connections
def get_names_of_rpc_by_class_type(
self, cls: type[BECWidget] | type[BECConnector] | type[BECDock] | type[BECDockArea]
) -> list[str]:
"""Get all the names of the widgets.
Args:
cls(BECWidget | BECConnector): The class of the RPC object to be retrieved.
"""
# 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.object_name for widget in widgets]
def broadcast(self):
"""
Broadcast the update to all the callbacks.
"""
if self._skip_broadcast:
return
connections = self.list_all_connections()
for callback in self.callbacks:
callback(connections)
def add_callback(self, callback: Callable[[dict], None]):
"""
Add a callback that will be called whenever the registry is updated.
Args:
callback(Callable[[dict], None]): The callback to be added. It should accept a dictionary of all the
registered RPC objects as an argument.
"""
self.callbacks.append(callback)
@classmethod
def reset_singleton(cls):
"""
@@ -153,25 +80,3 @@ class RPCRegister:
"""
cls._instance = None
cls._initialized = False
class RPCRegisterBroadcast:
"""Context manager for RPCRegister broadcast."""
def __init__(self, rpc_register: RPCRegister) -> None:
self.rpc_register = rpc_register
self._call_depth = 0
def __enter__(self):
"""Enter the context manager"""
self._call_depth += 1 # Needed for nested calls
self.rpc_register._skip_broadcast = True
return self.rpc_register
def __exit__(self, *exc):
"""Exit the context manager"""
self._call_depth -= 1 # Remove nested calls
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,6 @@
from __future__ import annotations
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils import BECConnector
class RPCWidgetHandler:
@@ -13,7 +10,7 @@ class RPCWidgetHandler:
self._widget_classes = None
@property
def widget_classes(self) -> dict[str, type[BECWidget]]:
def widget_classes(self):
"""
Get the available widget classes.
@@ -22,7 +19,7 @@ class RPCWidgetHandler:
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes # type: ignore
return self._widget_classes
def update_available_widgets(self):
"""
@@ -34,23 +31,22 @@ class RPCWidgetHandler:
from bec_widgets.utils.plugin_utils import get_custom_classes
clss = get_custom_classes("bec_widgets")
self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
self._widget_classes = {cls.__name__: cls for cls in clss.widgets}
def create_widget(self, widget_type, **kwargs) -> BECWidget:
def create_widget(self, widget_type, **kwargs) -> BECConnector:
"""
Create a widget from an RPC message.
Args:
widget_type(str): The type of the widget.
name (str): The name of the widget.
**kwargs: The keyword arguments for the widget.
Returns:
widget(BECWidget): The created widget.
widget(BECConnector): The created widget.
"""
widget_class = self.widget_classes.get(widget_type) # type: ignore
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
if widget_class:
return widget_class(**kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -1,27 +1,182 @@
from __future__ import annotations
import argparse
import functools
import json
import os
import signal
import sys
from contextlib import redirect_stderr, redirect_stdout
from typing import cast
import types
from contextlib import contextmanager, redirect_stderr, redirect_stdout
from typing import Union
import msgpack
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
from bec_lib.utils.import_utils import lazy_import
from cryptography.fernet import Fernet
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
import bec_widgets
from bec_widgets.applications.launch_window import LaunchWindow
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.bec_connector import BECConnector
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
@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
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: Union[BECFigure, BECDockArea] = BECFigure,
token: str = None,
) -> None:
self._fernet = Fernet(token) if token else None
self._init_acls(config)
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
self.gui = gui_class(gui_id=self.gui_id)
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
# self.dispatcher.connect_slot(self.on_acl_update, MessageEndpoints.gui_acl(self.gui_id))
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
logger.success(f"Server started with gui_id: {self.gui_id}")
def _init_acls(self, config: ServiceConfig):
acl_data = os.getenv("BEC_GUI_ACL")
if not acl_data:
return
acl_data = msgpack.loads(self._fernet.decrypt(acl_data))
config.config["acl"] = acl_data
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 on_acl_update(self, msg: dict, metadata: dict):
logger.debug(f"Received ACL update: {msg}, metadata: {metadata}")
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):
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):
return {
"gui_id": obj.gui_id,
"widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(),
"__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 shutdown(self): # TODO not sure if needed when cleanup is done at level of BECConnector
logger.info(f"Shutting down server with gui_id: {self.gui_id}")
self.status = messages.BECStatus.IDLE
self._heartbeat_timer.stop()
self.emit_heartbeat()
self.gui.close()
self.client.shutdown()
class SimpleFileLikeFromLogOutputFunc:
@@ -42,134 +197,33 @@ class SimpleFileLikeFromLogOutputFunc:
return
class GUIServer:
"""
This class is used to start the BEC GUI and is the main entry point for launching BEC Widgets in a subprocess.
"""
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], 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()
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)
if len(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()
token = os.getenv("BEC_GUI_TOKEN")
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, token=token
)
return server
def main():
"""
Main entry point for subprocesses that start a GUI server.
"""
import argparse
import os
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
parser = argparse.ArgumentParser(description="BEC Widgets CLI Server")
parser.add_argument("--id", type=str, default="test", help="The id of the server")
@@ -178,23 +232,76 @@ def main():
type=str,
help="Name of the gui class to be rendered. Possible values: \n- BECFigure\n- BECDockArea",
)
parser.add_argument(
"--gui_class_id",
type=str,
default="bec",
help="The id of the gui class that is added to the QApplication",
)
parser.add_argument("--config", type=str, help="Config file or config string.")
parser.add_argument("--hide", action="store_true", help="Hide on startup")
args = parser.parse_args()
server = GUIServer(args)
server.start()
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
elif args.gui_class == "BECFigure":
gui_class = BECFigure
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
server = _start_server(args.id, gui_class, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC Widgets")
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")
# first hide all top level windows
# this is to discriminate the cases between "user clicks on [X]"
# (which should be filtered, to not close -see BECDockArea-)
# or "app is asked to close"
for window in app.topLevelWidgets():
window.hide() # so, we know we can exit because it is hidden
app.quit()
signal.signal(signal.SIGINT, sigint_handler)
signal.signal(signal.SIGTERM, sigint_handler)
sys.exit(app.exec())
if __name__ == "__main__":
# import sys
# sys.argv = ["bec_widgets", "--gui_class", "MainWindow"]
main()

View File

@@ -15,16 +15,12 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
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.plot_base import PlotBase
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -34,34 +30,41 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
super().__init__(parent)
self._init_ui()
self.app = QApplication.instance()
# console push
if self.console.inprocess is True:
self.console.kernel_manager.kernel.shell.push(
{
"np": np,
"app": self.app,
"pg": pg,
"wh": wh,
"dock_area": self.dock_area,
"dock_1": self.dock_1,
"wf": self.wf,
# "dock_2": self.dock_2,
# "mm": self.mm,
# "lm": self.lm,
# "btn1": self.btn1,
# "btn2": self.btn2,
# "btn3": self.btn3,
# "btn4": self.btn4,
# "btn5": self.btn5,
# "btn6": self.btn6,
# "pb": self.pb,
# "pi": self.pi,
# "wf": self.wf,
# "scatter": self.scatter,
# "scatter_mi": self.scatter,
# "mwf": self.mwf,
"fig": self.figure,
"dock": self.dock,
"w1": self.w1,
"w2": self.w2,
"w3": self.w3,
"w4": self.w4,
"w5": self.w5,
"w6": self.w6,
"w7": self.w7,
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
"im": self.im,
"mm": self.mm,
"mw": self.mw,
"lm": self.lm,
"btn1": self.btn1,
"btn2": self.btn2,
"btn3": self.btn3,
"btn4": self.btn4,
"btn5": self.btn5,
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
}
)
@@ -74,91 +77,132 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
tab_widget = QTabWidget(splitter)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock)
tab_widget.addTab(first_tab, "Dock Area")
second_tab = QWidget()
second_tab_layout = QVBoxLayout(second_tab)
self.figure = BECFigure(parent=self, gui_id="figure")
second_tab_layout.addWidget(self.figure)
tab_widget.addTab(second_tab, "BEC Figure")
third_tab = QWidget()
third_tab_layout = QVBoxLayout(third_tab)
self.lm = LayoutManagerWidget()
third_tab_layout.addWidget(self.lm)
tab_widget.addTab(third_tab, "Layout Manager Widget")
fourth_tab = QWidget()
fourth_tab_layout = QVBoxLayout(fourth_tab)
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PltoBase")
tab_widget.setCurrentIndex(3)
group_box = QGroupBox("Jupyter Console", splitter)
group_box_layout = QVBoxLayout(group_box)
self.console = BECJupyterConsole(inprocess=True)
group_box_layout.addWidget(self.console)
first_tab = QWidget()
first_tab_layout = QVBoxLayout(first_tab)
self.dock_area = BECDockArea(gui_id="dock")
first_tab_layout.addWidget(self.dock_area)
tab_widget.addTab(first_tab, "Dock Area")
self._init_dock()
# Some buttons for layout testing
self.btn1 = QPushButton("Button 1")
self.btn2 = QPushButton("Button 2")
self.btn3 = QPushButton("Button 3")
self.btn4 = QPushButton("Button 4")
self.btn5 = QPushButton("Button 5")
self.btn6 = QPushButton("Button 6")
# third_tab = QWidget()
# third_tab_layout = QVBoxLayout(third_tab)
# self.lm = LayoutManagerWidget()
# third_tab_layout.addWidget(self.lm)
# tab_widget.addTab(third_tab, "Layout Manager Widget")
#
# fourth_tab = QWidget()
# fourth_tab_layout = QVBoxLayout(fourth_tab)
# self.pb = PlotBase()
# self.pi = self.pb.plot_item
# fourth_tab_layout.addWidget(self.pb)
# tab_widget.addTab(fourth_tab, "PlotBase")
#
# tab_widget.setCurrentIndex(3)
#
#
#
# # Some buttons for layout testing
# self.btn1 = QPushButton("Button 1")
# self.btn2 = QPushButton("Button 2")
# self.btn3 = QPushButton("Button 3")
# self.btn4 = QPushButton("Button 4")
# self.btn5 = QPushButton("Button 5")
# self.btn6 = QPushButton("Button 6")
#
# fifth_tab = QWidget()
# fifth_tab_layout = QVBoxLayout(fifth_tab)
# self.wf = Waveform()
# fifth_tab_layout.addWidget(self.wf)
# tab_widget.addTab(fifth_tab, "Waveform Next Gen")
# tab_widget.setCurrentIndex(4)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)
# self.mm = MotorMap()
# eighth_tab_layout.addWidget(self.mm)
# tab_widget.addTab(eighth_tab, "Motor Map")
# tab_widget.setCurrentIndex(7)
#
# ninth_tab = QWidget()
# ninth_tab_layout = QVBoxLayout(ninth_tab)
# self.mwf = MultiWaveform()
# ninth_tab_layout.addWidget(self.mwf)
# tab_widget.addTab(ninth_tab, "MultiWaveform")
# tab_widget.setCurrentIndex(0)
#
# # add stuff to the new Waveform widget
# self._init_waveform()
# add stuff to figure
self._init_figure()
# init dock for testing
self._init_dock()
self.setWindowTitle("Jupyter Console Window")
def _init_waveform(self):
self.wf.plot(y_name="bpm4i", y_entry="bpm4i", dap="GaussianModel")
self.wf.plot(y_name="bpm3a", y_entry="bpm3a", dap="GaussianModel")
def _init_figure(self):
self.w1 = self.figure.plot(x_name="samx", y_name="bpm4i", row=0, col=0)
self.w1.set(
title="Standard Plot with sync device, custom labels - w1",
x_label="Motor Position",
y_label="Intensity (A.U.)",
)
self.w2 = self.figure.motor_map("samx", "samy", row=0, col=1)
self.w3 = self.figure.image(
"eiger", color_map="viridis", vrange=(0, 100), title="Eiger Image - w3", row=0, col=2
)
self.w4 = self.figure.plot(
x_name="samx",
y_name="samy",
z_name="bpm4i",
color_map_z="magma",
new=True,
title="2D scatter plot - w4",
row=0,
col=3,
)
self.w5 = self.figure.plot(
y_name="bpm4i",
new=True,
title="Best Effort Plot - w5",
dap="GaussianModel",
row=1,
col=0,
)
self.w6 = self.figure.plot(
x_name="timestamp", y_name="bpm4i", new=True, title="Timestamp Plot - w6", row=1, col=1
)
self.w7 = self.figure.plot(
x_name="index", y_name="bpm4i", new=True, title="Index Plot - w7", row=1, col=2
)
self.w8 = self.figure.plot(
y_name="monitor_async", new=True, title="Async Plot - Best Effort - w8", row=2, col=0
)
self.w9 = self.figure.plot(
x_name="timestamp",
y_name="monitor_async",
new=True,
title="Async Plot - timestamp - w9",
row=2,
col=1,
)
self.w10 = self.figure.plot(
x_name="index",
y_name="monitor_async",
new=True,
title="Async Plot - index - w10",
row=2,
col=2,
)
def _init_dock(self):
self.dock_1 = self.dock_area.new(name="dock_0")
self.wf = self.dock_1.new(widget="Waveform")
# self.dock_2 = self.dock_area.new(widget="DarkModeButton")
self.d0 = self.dock.add_dock(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget")
self.mm.change_motors("samx", "samy")
self.d1 = self.dock.add_dock(name="dock_1", position="right")
self.im = self.d1.add_widget("BECImageWidget")
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
self.dock.save_state()
def closeEvent(self, event):
"""Override to handle things when main window is closed."""
self.dock_area.cleanup()
self.dock_area.close()
self.dock.cleanup()
self.dock.close()
self.figure.cleanup()
self.figure.close()
self.console.close()
super().closeEvent(event)
@@ -174,7 +218,8 @@ if __name__ == "__main__": # pragma: no cover
app = QApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
apply_theme("dark")
icon = material_icon("terminal", color="#434343", filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
@@ -183,7 +228,7 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow()
win.show()
win.resize(1500, 800)
win.resize(1200, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -6,7 +6,7 @@ from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from bec_widgets.examples.plugin_example_pyside.tictactoe import TicTacToe
from bec_widgets.utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
class TicTacToeDialog(QDialog): # pragma: no cover

View File

@@ -12,7 +12,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
class ExpandableGroupFrame(QFrame):

View File

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

View File

@@ -2,10 +2,11 @@ import pyqtgraph as pg
from qtpy.QtCore import Property
from qtpy.QtWidgets import QApplication, QFrame, QHBoxLayout, QVBoxLayout, QWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
class RoundedFrame(QFrame):
class RoundedFrame(BECWidget, QFrame):
"""
A custom QFrame with rounded corners and optional theme updates.
The frame can contain any QWidget, however it is mainly designed to wrap PlotWidgets to provide a consistent look and feel with other BEC Widgets.
@@ -16,15 +17,20 @@ class RoundedFrame(QFrame):
parent=None,
content_widget: QWidget = None,
background_color: str = None,
theme_update: bool = True,
radius: int = 10,
**kwargs,
):
super().__init__(**kwargs)
QFrame.__init__(self, parent)
self.background_color = background_color
self.theme_update = theme_update if background_color is None else False
self._radius = radius
# Apply rounded frame styling
self.setProperty("skip_settings", True)
self.setObjectName("roundedFrame")
# Create a layout for the frame
self.layout = QHBoxLayout(self)
@@ -40,14 +46,14 @@ class RoundedFrame(QFrame):
# Automatically apply initial styles to the GraphicalLayoutWidget if applicable
self.apply_plot_widget_style()
self._connect_to_theme_change()
def apply_theme(self, theme: str):
"""
Apply the theme to the frame and its content if theme updates are enabled.
"""
if self.content_widget is not None and isinstance(
self.content_widget, pg.GraphicsLayoutWidget
):
self.content_widget.setBackground(self.background_color)
if not self.theme_update:
return
# Update background color based on the theme
if theme == "light":
@@ -123,8 +129,8 @@ class ExampleApp(QWidget): # pragma: no cover
plot2.plot_item = plot_item_2
# Wrap PlotWidgets in RoundedFrame
rounded_plot1 = RoundedFrame(parent=self, content_widget=plot1)
rounded_plot2 = RoundedFrame(parent=self, content_widget=plot2)
rounded_plot1 = RoundedFrame(content_widget=plot1, theme_update=True)
rounded_plot2 = RoundedFrame(content_widget=plot2, theme_update=True)
# Add to layout
layout.addWidget(dark_button)

View File

@@ -1,10 +1,6 @@
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
from bec_widgets.qt_utils.error_popups import SafeSlot
class SettingWidget(QWidget):
@@ -41,15 +37,6 @@ 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):
"""
@@ -112,17 +99,8 @@ 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):
"""
@@ -136,10 +114,7 @@ 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

@@ -16,7 +16,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
class SidePanel(QWidget):
@@ -35,6 +35,7 @@ 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
@@ -231,14 +232,7 @@ class SidePanel(QWidget):
self.stack_widget.setCurrentIndex(idx)
self.current_index = idx
def add_menu(
self,
action_id: str,
icon_name: str,
tooltip: str,
widget: QWidget,
title: str | None = None,
):
def add_menu(self, action_id: str, icon_name: str, tooltip: str, widget: QWidget, title: str):
"""
Add a menu to the side panel.
@@ -255,10 +249,9 @@ class SidePanel(QWidget):
container_layout.setContentsMargins(0, 0, 0, 0)
container_layout.setSpacing(5)
if title is not None:
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
container_layout.addWidget(title_label)
title_label = QLabel(f"<b>{title}</b>")
title_label.setStyleSheet("font-size: 16px;")
container_layout.addWidget(title_label)
# Create a QScrollArea for the actual widget to ensure scrolling if the widget inside is too large
scroll_area = QScrollArea()
@@ -324,9 +317,9 @@ class ExampleApp(QMainWindow): # pragma: no cover
self.side_panel = SidePanel(self, orientation="left", panel_max_width=250)
self.layout.addWidget(self.side_panel)
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
self.plot = Waveform()
self.plot = BECWaveformWidget()
self.layout.addWidget(self.plot)
self.add_side_menus()

View File

@@ -279,6 +279,7 @@ class SwitchableToolBarAction(ToolBarAction):
self.main_button.setToolTip(default_action.tooltip)
self.main_button.clicked.connect(self._trigger_current_action)
menu = QMenu(self.main_button)
self.menu_actions = {}
for key, action_obj in self.actions.items():
menu_action = QAction(action_obj.get_icon(), action_obj.tooltip, self.main_button)
menu_action.setIconVisibleInMenu(True)
@@ -286,54 +287,23 @@ class SwitchableToolBarAction(ToolBarAction):
menu_action.setChecked(key == self.current_key)
menu_action.triggered.connect(lambda checked, k=key: self.set_default_action(k))
menu.addAction(menu_action)
self.menu_actions[key] = menu_action
self.main_button.setMenu(menu)
toolbar.addWidget(self.main_button)
def _trigger_current_action(self):
"""
Triggers the current action associated with the main button.
"""
action_obj = self.actions[self.current_key]
action_obj.action.trigger()
def set_default_action(self, key: str):
"""
Sets the default action for the split action.
Args:
key(str): The key of the action to set as default.
"""
self.current_key = key
new_action = self.actions[self.current_key]
self.main_button.setIcon(new_action.get_icon())
self.main_button.setToolTip(new_action.tooltip)
# Update check state of menu items
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
for k, menu_act in self.menu_actions.items():
menu_act.setChecked(k == key)
new_action.action.trigger()
# Active action chosen from menu is always checked, uncheck through main button
if self.checkable:
new_action.action.setChecked(True)
self.main_button.setChecked(True)
def block_all_signals(self, block: bool = True):
"""
Blocks or unblocks all signals for the actions in the toolbar.
Args:
block (bool): Whether to block signals. Defaults to True.
"""
self.main_button.blockSignals(block)
for action in self.actions.values():
action.action.blockSignals(block)
def set_state_all(self, state: bool):
"""
Uncheck all actions in the toolbar.
"""
for action in self.actions.values():
action.action.setChecked(state)
self.main_button.setChecked(state)
def get_icon(self) -> QIcon:
return self.actions[self.current_key].get_icon()
@@ -348,18 +318,11 @@ class WidgetAction(ToolBarAction):
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
parent=None,
):
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
super().__init__(icon_path=None, tooltip=label, checkable=False)
self.label = label
self.widget = widget
self.container = None
self.adjust_size = adjust_size
def add_to_toolbar(self, toolbar: QToolBar, target: QWidget):
"""
@@ -380,7 +343,7 @@ class WidgetAction(ToolBarAction):
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox) and self.adjust_size:
if isinstance(self.widget, QComboBox):
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -858,13 +821,13 @@ class MainWindow(QMainWindow): # pragma: no cover
# For theme testing
self.dark_button = DarkModeButton(parent=self, toolbar=True)
self.dark_button = DarkModeButton(toolbar=True)
dark_mode_action = WidgetAction(label=None, widget=self.dark_button)
self.toolbar.add_action("dark_mode", dark_mode_action, self)
def add_bundles(self):
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=False, parent=self
icon_name="home", tooltip="Home", checkable=True, parent=self
)
settings_action = MaterialIconAction(
icon_name="settings", tooltip="Settings", checkable=True, parent=self
@@ -881,7 +844,6 @@ class MainWindow(QMainWindow): # pragma: no cover
],
)
self.toolbar.add_bundle(main_actions_bundle, target_widget=self)
home_action.action.triggered.connect(lambda: self.switchable_action.set_state_all(False))
search_action = MaterialIconAction(
icon_name="search", tooltip="Search", checkable=False, parent=self
@@ -935,20 +897,20 @@ class MainWindow(QMainWindow): # pragma: no cover
def add_switchable_button_checkable(self):
action1 = MaterialIconAction(
icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
)
action2 = MaterialIconAction(
icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
)
self.switchable_action = SwitchableToolBarAction(
switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action", self.switchable_action, self)
self.toolbar.add_action("switchable_action", switchable_action, self)
action1.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
@@ -969,20 +931,16 @@ class MainWindow(QMainWindow): # pragma: no cover
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=False,
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action_no_toggle", switchable_action, self)
action1.action.triggered.connect(
lambda checked: self.test_label.setText(
f"Action 1 (non-checkable) triggered, checked = {checked}"
)
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
)
action2.action.triggered.connect(
lambda checked: self.test_label.setText(
f"Action 2 (non-checkable) triggered, checked = {checked}"
)
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
)
switchable_action.actions["action1"].action.setChecked(True)

View File

@@ -4,29 +4,24 @@ from __future__ import annotations
import os
import time
import uuid
from datetime import datetime
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, Qt, QThreadPool, Signal
from qtpy.QtCore import QObject, QRunnable, QThreadPool, 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.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING: # pragma: no cover
if TYPE_CHECKING:
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):
@@ -44,7 +39,8 @@ class ConnectionConfig(BaseModel):
"""Generate a GUI ID if none is provided."""
if v is None:
widget_class = values.data["widget_class"]
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
v = f"{widget_class}_{str(time.time())}"
return v
return v
@@ -79,26 +75,10 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
object_name: str | None = None,
parent_dock: BECDock | None = None, # TODO should go away
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
super().__init__(parent=parent, **kwargs)
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
# 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 # TODO also remove at some point
if not self.client in BECConnector.EXIT_HANDLERS:
# register function to clean connections at exit;
@@ -123,32 +103,15 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
self.parent_id = parent_id
# If the gui_id is passed, it should be respected. However, this should be revisted since
# the gui_id has to be unique, and may no longer be.
if gui_id:
self.config.gui_id = gui_id
self.gui_id: str = gui_id # Keep namespace in sync
self.gui_id = gui_id
else:
self.gui_id: str = self.config.gui_id # type: ignore
self.gui_id = self.config.gui_id
# TODO Hierarchy can be refreshed upon creation -> also registry should be notified if objectName changes -> issue #472
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
self._enforce_unique_sibling_name()
# register widget to rpc register
# be careful: when registering, and the object is not a BECWidget,
# cleanup has to be called manually since there is no 'closeEvent'
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
@@ -159,49 +122,6 @@ class BECConnector:
# Store references to running workers so they're not garbage collected prematurely.
self._workers = []
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.
"""
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.objectName()
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
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
@@ -275,7 +195,6 @@ class BECConnector:
"""
self.config = config
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def apply_config(self, config: dict, generate_new_id: bool = True) -> None:
"""
Apply the configuration to the widget.
@@ -288,12 +207,11 @@ class BECConnector:
if generate_new_id is True:
gui_id = str(uuid.uuid4())
self.rpc_register.remove_rpc(self)
self._set_gui_id(gui_id)
self.set_gui_id(gui_id)
self.rpc_register.add_rpc(self)
else:
self.gui_id = self.config.gui_id
# FIXME some thoughts are required to decide how thhis should work with rpc registry
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
@@ -330,8 +248,8 @@ class BECConnector:
file_path = os.path.join(path, f"{self.__class__.__name__}_config.yaml")
save_yaml(file_path, self._config_dict)
# @pyqtSlot(str)
def _set_gui_id(self, gui_id: str) -> None:
@pyqtSlot(str)
def set_gui_id(self, gui_id: str) -> None:
"""
Set the GUI ID for the widget.
@@ -370,26 +288,9 @@ class BECConnector:
Args:
config (ConnectionConfig | dict): Configuration settings.
"""
gui_id = getattr(config, "gui_id", None)
if isinstance(config, dict):
config = ConnectionConfig(**config)
self.config = config
if gui_id and config.gui_id != gui_id: # Recreating config should not overwrite the gui_id
self.config.gui_id = gui_id
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
if self._parent_dock is not None:
self._parent_dock.delete(self.object_name)
# If the widget is from Qt, trigger its close method.
elif hasattr(self, "close"):
self.close()
# If the widget is neither from a Dock nor from Qt, remove it from the RPC registry.
# i.e. Curve Item from Waveform
else:
self.rpc_register.remove_rpc(self)
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""

View File

@@ -1,8 +1,6 @@
from __future__ import annotations
import collections
import random
import string
from collections.abc import Callable
from typing import TYPE_CHECKING, Union
@@ -19,8 +17,6 @@ logger = bec_logger.logger
if TYPE_CHECKING:
from bec_lib.endpoints import EndpointInfo
from bec_widgets.utils.cli_server import CLIServer
class QtThreadSafeCallback(QObject):
cb_signal = pyqtSignal(dict, dict)
@@ -77,24 +73,14 @@ class BECDispatcher:
_instance = None
_initialized = False
client: BECClient
cli_server: CLIServer | None = None
# TODO add custom gui id for server
def __new__(
cls,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str = None,
*args,
**kwargs,
):
def __new__(cls, client=None, config: 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 = None, gui_id: str = None):
def __init__(self, client=None, config: str | ServiceConfig = None):
if self._initialized:
return
@@ -122,15 +108,10 @@ 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
@@ -197,49 +178,4 @@ 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.cli_server import CLIServer
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 = CLIServer(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

@@ -1,225 +0,0 @@
from __future__ import annotations
import os
import random
import string
from typing import TYPE_CHECKING, Any
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from qtpy.QtCore import QSize
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QApplication
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_dispatcher import BECDispatcher
from bec_widgets.utils.cli_server import CLIServer
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
if TYPE_CHECKING: # pragma: no cover
from bec_lib.client import BECClient
class BECApplication:
"""
Custom QApplication class for BEC applications.
"""
gui_id: str
dispatcher: BECDispatcher
rpc_register: RPCRegister
client: BECClient
is_bec_app: bool
cli_server: CLIServer
_instance: BECApplication
_initialized: bool
def __init__(
self,
*args,
client=None,
config: str | ServiceConfig | None = None,
gui_id: str | None = None,
**kwargs,
):
if self._initialized:
return
self.app = QApplication.instance()
if self.app is None:
self.app = QApplication([])
self._initialize_bec_app(client, config, gui_id)
self._initialized = True
def _initialize_bec_app(
self, client=None, config: str | ServiceConfig | None = None, gui_id: str | None = None
):
"""
Initialize the BECApplication instance with the given client and configuration.
Args:
app: The QApplication instance to initialize.
client: The BECClient instance to use for communication.
config: The ServiceConfig instance to use for configuration.
gui_id: The unique identifier for this application.
"""
self.app.gui_id = gui_id or BECApplication.generate_unique_identifier()
self.app.dispatcher = BECDispatcher(client=client, config=config)
self.app.rpc_register = RPCRegister()
self.app.client = self.app.dispatcher.client # type: ignore
self.app.is_bec_app = True
self.app.aboutToQuit.connect(self.shutdown)
self.setup_bec_icon()
def __instancecheck__(self, instance: Any) -> bool:
return isinstance(instance, (QApplication, BECApplication))
def __getattr__(self, name: str) -> Any:
if hasattr(self.app, name):
return getattr(self.app, name)
return super().__getattribute__(name)
def __new__(cls, *args, **kwargs) -> BECApplication:
if not hasattr(cls, "_instance"):
cls._instance = super().__new__(cls)
cls._initialized = False
return cls._instance
@classmethod
def from_qapplication(
cls, client=None, config: str | ServiceConfig | None = None, gui_id: str | None = None
) -> BECApplication:
"""
Create a BECApplication instance from an existing QApplication instance.
"""
print("from_qapplication")
app = QApplication.instance()
if isinstance(app, BECApplication):
return app
return cls(client=client, config=config, gui_id=gui_id)
def setup_bec_icon(self):
"""
Set the BEC icon for the application
"""
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.setWindowIcon(icon)
@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))
# # TODO not sure if needed
# def register_all(self):
# widgets = self.allWidgets()
# all_connections = self.rpc_register.list_all_connections()
# for widget in widgets:
# if not isinstance(widget, BECWidget):
# continue
# gui_id = getattr(widget, "gui_id", None)
# if gui_id and widget not in all_connections:
# self.rpc_register.add_rpc(widget)
# print(
# f"[BECQApplication]: Registered widget {widget.__class__} with GUI ID: {gui_id}"
# )
# # TODO not sure if needed
# def list_all_bec_widgets(self):
# widgets = self.allWidgets()
# bec_widgets = []
# for widget in widgets:
# if isinstance(widget, BECWidget):
# bec_widgets.append(widget)
# return bec_widgets
# def list_hierarchy(self, only_bec_widgets: bool = True, show_parent: bool = True):
# """
# List the hierarchy of all BECWidgets in this application.
# Args:
# only_bec_widgets (bool): If True, prints only BECWidgets. Non-BECWidgets are skipped but their children are still traversed.
# show_parent (bool): If True, displays the immediate BECWidget ancestor for each item.
# """
# bec_widgets = self.list_all_bec_widgets()
# # Identify top-level BECWidgets (whose parent is not another BECWidget)
# top_level = [
# w for w in bec_widgets if not isinstance(self._get_becwidget_ancestor(w), BECWidget)
# ]
# print("[BECQApplication]: Listing BECWidget hierarchy:")
# for widget in top_level:
# self._print_becwidget_hierarchy(
# widget, indent=0, only_bec_widgets=only_bec_widgets, show_parent=show_parent
# )
# def _print_becwidget_hierarchy(self, widget, indent=0, only_bec_widgets=True, show_parent=True):
# # Decide if this widget should be printed
# is_bec = isinstance(widget, BECWidget)
# print_this = (not only_bec_widgets) or is_bec
# parent_info = ""
# if show_parent and is_bec:
# ancestor = self._get_becwidget_ancestor(widget)
# if ancestor is not None:
# parent_info = f" parent={ancestor.__class__.__name__}"
# else:
# parent_info = " parent=None"
# if print_this:
# prefix = " " * indent
# print(
# f"{prefix}- {widget.__class__.__name__} (objectName={widget.objectName()}){parent_info}"
# )
# # Always recurse so deeper BECWidgets aren't missed
# for child in widget.children():
# # Skip known non-BECWidgets if only_bec_widgets is True, but keep recursion
# # We'll still call _print_becwidget_hierarchy to discover any BECWidget descendants.
# self._print_becwidget_hierarchy(
# child, indent + 2, only_bec_widgets=only_bec_widgets, show_parent=show_parent
# )
# def _get_becwidget_ancestor(self, widget):
# """
# Climb the .parent() chain until finding another BECWidget, or None.
# """
# p = widget.parent()
# while p is not None:
# if isinstance(p, BECWidget):
# return p
# p = p.parent()
# return None
def shutdown(self):
self.dispatcher.disconnect_all()
self.cli_server.shutdown()
self.rpc_register.reset_singleton()
delattr(self.app, "gui_id")
delattr(self.app, "dispatcher")
delattr(self.app, "rpc_register")
delattr(self.app, "client")
delattr(self.app, "is_bec_app")
delattr(self.app, "cli_server")
self._initialized = False
self._instance = None

View File

@@ -7,7 +7,7 @@ will allow you to decide by yourself when to unblock and execute the callback ag
from pyqtgraph import SignalProxy
from qtpy.QtCore import QTimer, Signal
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.qt_utils.error_popups import SafeSlot
class BECSignalProxy(SignalProxy):

View File

@@ -1,20 +1,13 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from PySide6.QtCore import QObject
from qtpy.QtCore import Slot
from qtpy.QtWidgets import QApplication
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -24,17 +17,13 @@ class BECWidget(BECConnector):
# The icon name is the name of the icon in the icon theme, typically a name taken
# from fonts.google.com/icons. Override this in subclasses to set the icon name.
ICON_NAME = "widgets"
USER_ACCESS = ["remove"]
# pylint: disable=too-many-arguments
def __init__(
self,
client=None,
config: ConnectionConfig = None,
gui_id: str | None = None,
gui_id: str = None,
theme_update: bool = False,
parent_dock: BECDock | None = None, # TODO should not be there
parent_id: str | None = None,
**kwargs,
):
"""
@@ -54,17 +43,11 @@ 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.
"""
super().__init__(
client=client,
config=config,
gui_id=gui_id,
parent_dock=parent_dock,
parent_id=parent_id,
**kwargs,
)
if not isinstance(self, QObject):
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, **kwargs)
# Set the theme to auto if it is not set yet
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -78,13 +61,6 @@ class BECWidget(BECConnector):
logger.debug(f"Subscribing to theme updates for {self.__class__.__name__}")
self._connect_to_theme_change()
def _ensure_bec_app(self):
# pylint: disable=import-outside-toplevel
from bec_widgets.utils.bec_qapp import BECApplication
app = BECApplication.from_qapplication()
return app
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
@@ -112,14 +88,10 @@ class BECWidget(BECConnector):
def cleanup(self):
"""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):
"""Wrap the close even to ensure the rpc_register is cleaned up."""
self.rpc_register.remove_rpc(self)
try:
self.cleanup()
finally:
super().closeEvent(event) # pylint: disable=no-member
super().closeEvent(event)

View File

@@ -1,222 +0,0 @@
from __future__ import annotations
import functools
import traceback
import types
from contextlib import contextmanager
from typing import TYPE_CHECKING
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
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.plots.plot_base import PlotBase
if TYPE_CHECKING:
from bec_lib import messages
else:
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 CLIServer:
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 as e:
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):
if isinstance(obj, BECConnector):
# Respect RPC = False
if hasattr(obj, "RPC") and obj.RPC is False:
return None
return self._serialize_bec_connector(obj)
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):
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) -> dict:
"""
Create the serialization dict for a single BECConnector,
setting 'parent_id' via the real nearest BECConnector parent.
"""
config_dict = connector.config.model_dump()
config_dict["parent_id"] = getattr(connector, "parent_id", None)
return {
"gui_id": connector.gui_id,
"object_name": connector.object_name or connector.__class__.__name__,
"widget_class": connector.__class__.__name__,
"config": config_dict,
"__rpc__": True,
}
@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
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):
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,55 +1,30 @@
from __future__ import annotations
import itertools
from typing import Literal, Type
from typing import Type
from qtpy.QtWidgets import QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
class WidgetContainerUtils:
# We need one handler that checks if a WIDGET of a given name is already created for that DOCKAREA
# 1. If the name exists, then it depends whether the name was auto-generated -> add _1 to the name
# or alternatively raise an error that it can't be added again ( just raise an error)
# 2. Dock names in between docks should also be unique
@staticmethod
def has_name_valid_chars(name: str) -> bool:
"""Check if the name is valid.
def generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
Args:
name(str): The name to be checked.
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
Returns:
bool: True if the name is valid, False otherwise.
widget_id(str): The unique widget ID.
"""
if not name or len(name) > 256:
return False # Don't accept empty names or names longer than 256 characters
check_value = name.replace("_", "").replace("-", "")
if not check_value.isalnum() or not check_value.isascii():
return False
return True
@staticmethod
def generate_unique_name(name: str, list_of_names: list[str] | None = None) -> str:
"""Generate a unique ID.
Args:
name(str): The name of the widget.
Returns:
tuple (str): The unique name
"""
if list_of_names is None:
list_of_names = []
ii = 0
while ii < 1000: # 1000 is arbritrary!
name_candidate = f"{name}_{ii}"
if name_candidate not in list_of_names:
return name_candidate
ii += 1
raise ValueError("Could not generate a unique name after within 1000 attempts.")
existing_ids = set(container.keys())
for i in itertools.count(1):
widget_id = f"{prefix}_{i}"
if widget_id not in existing_ids:
return widget_id
@staticmethod
def find_first_widget_by_class(

View File

@@ -1,7 +1,4 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any
import numpy as np
import pyqtgraph as pg
@@ -200,18 +197,15 @@ class Crosshair(QObject):
self.marker_2d = pg.ROI(
[0, 0], size=[1, 1], pen=pg.mkPen("r", width=2), movable=False
)
self.marker_2d.skip_auto_range = True
self.plot_item.addItem(self.marker_2d)
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
def snap_to_data(self, x, y) -> tuple[defaultdict[list], defaultdict[list]]:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x(float): The x-coordinate of the mouse cursor
y(float): The y-coordinate of the mouse cursor
x: The x-coordinate of the mouse cursor
y: The y-coordinate of the mouse cursor
Returns:
tuple: x and y values snapped to the nearest data
@@ -241,7 +235,7 @@ class Crosshair(QObject):
y_values[name] = closest_y
x_values[name] = closest_x
elif isinstance(item, pg.ImageItem): # 2D plot
name = item.config.monitor or str(id(item))
name = item.config.monitor
image_2d = item.image
# Clip the x and y values to the image dimensions to avoid out of bounds errors
y_values[name] = int(np.clip(y, 0, image_2d.shape[1] - 1))
@@ -326,7 +320,7 @@ class Crosshair(QObject):
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
@@ -380,7 +374,7 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor or str(id(item))
name = item.config.monitor
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
@@ -424,17 +418,9 @@ class Crosshair(QObject):
"""
x, y = pos
x_scaled, y_scaled = self.scale_emitted_coordinates(x, y)
text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})"
for item in self.items:
if isinstance(item, pg.ImageItem):
image = item.image
ix = int(np.clip(x, 0, image.shape[0] - 1))
iy = int(np.clip(y, 0, image.shape[1] - 1))
intensity = image[ix, iy]
text += f"\nIntensity: {intensity:.{self.precision}g}"
break
# Update coordinate label
self.coord_label.setText(text)
self.coord_label.setText(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
@@ -450,9 +436,6 @@ class Crosshair(QObject):
self.clear_markers()
def cleanup(self):
if self.marker_2d is not None:
self.plot_item.removeItem(self.marker_2d)
self.marker_2d = None
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.coord_label)

View File

@@ -22,9 +22,7 @@ class EntryValidator:
if entry is None or entry == "":
entry = next(iter(device._hints), name) if hasattr(device, "_hints") else name
if entry not in description:
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
raise ValueError(f"Entry '{entry}' not found in device '{name}' signals")
return entry

View File

@@ -148,7 +148,10 @@ class BECTickItem(BECIndicatorItem):
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
self.tick_item = None
if self.tick_item is not None:
self.tick_item.close()
self.tick_item.deleteLater()
self.tick_item = None
class BECArrowItem(BECIndicatorItem):
@@ -171,7 +174,7 @@ class BECArrowItem(BECIndicatorItem):
def __init__(self, plot_item: pg.PlotItem = None, parent=None):
super().__init__(plot_item=plot_item, parent=parent)
self.arrow_item = pg.ArrowItem()
self.arrow_item = pg.ArrowItem(parent=parent)
self.arrow_item.skip_auto_range = True
self._pos = (0, 0)
self.arrow_item.setVisible(False)

View File

@@ -1,14 +1,11 @@
import inspect
import os
from bec_lib.logger import bec_logger
from qtpy import PYQT6, PYSIDE6, QT_VERSION
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
@@ -21,18 +18,10 @@ if PYSIDE6:
def createWidget(self, class_name, parent=None, name=""):
if class_name in self.custom_widgets:
# check if the custom widget has a parent_id argument
if "parent_id" in inspect.signature(self.custom_widgets[class_name]).parameters:
gui_id = getattr(self.baseinstance, "gui_id", None)
widget = self.custom_widgets[class_name](self.baseinstance, parent_id=gui_id)
else:
logger.warning(
f"Custom widget {class_name} does not have a parent_id argument. "
)
widget = self.custom_widgets[class_name](self.baseinstance)
widget = self.custom_widgets[class_name](parent)
widget.setObjectName(name)
return widget
return super().createWidget(class_name, self.baseinstance, name)
return super().createWidget(class_name, parent, name)
class UILoader:
@@ -62,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

@@ -1,6 +1,4 @@
# pylint: disable=no-name-in-module
from __future__ import annotations
from abc import ABC, abstractmethod
from qtpy.QtWidgets import (
@@ -10,7 +8,6 @@ from qtpy.QtWidgets import (
QDoubleSpinBox,
QLabel,
QLineEdit,
QSlider,
QSpinBox,
QTableWidget,
QTableWidgetItem,
@@ -107,10 +104,10 @@ class TableWidgetHandler(WidgetHandler):
class SpinBoxHandler(WidgetHandler):
"""Handler for QSpinBox and QDoubleSpinBox widgets."""
def get_value(self, widget: QSpinBox | QDoubleSpinBox, **kwargs):
def get_value(self, widget, **kwargs):
return widget.value()
def set_value(self, widget: QSpinBox | QDoubleSpinBox, value):
def set_value(self, widget, value):
widget.setValue(value)
def connect_change_signal(self, widget: QSpinBox | QDoubleSpinBox, slot):
@@ -120,36 +117,23 @@ class SpinBoxHandler(WidgetHandler):
class CheckBoxHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget: QCheckBox, **kwargs):
def get_value(self, widget, **kwargs):
return widget.isChecked()
def set_value(self, widget: QCheckBox, value):
def set_value(self, widget, value):
widget.setChecked(value)
def connect_change_signal(self, widget: QCheckBox, slot):
widget.toggled.connect(lambda val, w=widget: slot(w, val))
class SlideHandler(WidgetHandler):
"""Handler for QCheckBox widgets."""
def get_value(self, widget: QSlider, **kwargs):
return widget.value()
def set_value(self, widget: QSlider, value):
widget.setValue(value)
def connect_change_signal(self, widget: QSlider, slot):
widget.valueChanged.connect(lambda val, w=widget: slot(w, val))
class ToggleSwitchHandler(WidgetHandler):
"""Handler for ToggleSwitch widgets."""
def get_value(self, widget: ToggleSwitch, **kwargs):
def get_value(self, widget, **kwargs):
return widget.checked
def set_value(self, widget: ToggleSwitch, value):
def set_value(self, widget, value):
widget.checked = value
def connect_change_signal(self, widget: ToggleSwitch, slot):
@@ -159,7 +143,7 @@ class ToggleSwitchHandler(WidgetHandler):
class LabelHandler(WidgetHandler):
"""Handler for QLabel widgets."""
def get_value(self, widget: QLabel, **kwargs):
def get_value(self, widget, **kwargs):
return widget.text()
def set_value(self, widget: QLabel, value):
@@ -181,7 +165,6 @@ class WidgetIO:
QCheckBox: CheckBoxHandler,
QLabel: LabelHandler,
ToggleSwitch: ToggleSwitchHandler,
QSlider: SlideHandler,
}
@staticmethod
@@ -275,160 +258,39 @@ 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(str,optional): Custom string prefix for indentation.
prefix(stc,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.
"""
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.plots.waveform.waveform import Waveform
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
# 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)
# 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):
children = widget.children()
for child in children:
if (
exclude_internal_widgets
and isinstance(widget, QComboBox)
and child.__class__.__name__ in ["QFrame", "QBoxLayout", "QListView"]
):
continue
# if WidgetHierarchy._get_becwidget_ancestor(child) == widget:
child_prefix = prefix + " └─ "
child_prefix = prefix + " "
arrow = "├─ " if child != children[-1] else "└─ "
WidgetHierarchy.print_widget_hierarchy(
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,
child, indent + 1, grab_values, prefix=child_prefix + arrow
)
@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
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

@@ -1,32 +1,25 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
from typing import TYPE_CHECKING, Any, Literal, Optional
from bec_lib.logger import bec_logger
from pydantic import Field
from pyqtgraph.dockarea import Dock, DockLabel
from qtpy import QtCore, QtGui
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import ConnectionConfig, GridLayoutManager
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
logger = bec_logger.logger
if TYPE_CHECKING:
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class DockConfig(ConnectionConfig):
widgets: dict[str, Any] = Field({}, description="The widgets in the dock.")
position: Literal["bottom", "top", "left", "right", "above", "below"] = Field(
"bottom", description="The position of the dock."
)
parent_dock_area: Optional[str] | None = Field(
parent_dock_area: Optional[str] = Field(
None, description="The GUI ID of parent dock area of the dock."
)
@@ -110,17 +103,16 @@ class BECDock(BECWidget, Dock):
ICON_NAME = "widgets"
USER_ACCESS = [
"_config_dict",
"element_list",
"elements",
"new",
"show",
"hide",
"_rpc_id",
"widget_list",
"show_title_bar",
"set_title",
"hide_title_bar",
"available_widgets",
"delete",
"delete_all",
"get_widgets_positions",
"set_title",
"add_widget",
"list_eligible_widgets",
"move_widget",
"remove_widget",
"remove",
"attach",
"detach",
@@ -129,39 +121,29 @@ class BECDock(BECWidget, Dock):
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
parent_id: str | None = None,
parent_dock_area: QWidget | 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,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
label = CustomDockLabel(text=name, closable=closable)
super().__init__(
parent=parent_dock_area,
name=name,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
label=label,
**kwargs,
)
Dock.__init__(self, name=name, label=label, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
@@ -191,18 +173,7 @@ class BECDock(BECWidget, Dock):
super().float()
@property
def elements(self) -> dict[str, BECWidget]:
"""
Get the widgets in the dock.
Returns:
widgets(dict): The widgets in the dock.
"""
# pylint: disable=protected-access
return dict((widget.object_name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
def widget_list(self) -> list[BECWidget]:
"""
Get the widgets in the dock.
@@ -211,6 +182,10 @@ class BECDock(BECWidget, Dock):
"""
return self.widgets
@widget_list.setter
def widget_list(self, value: list[BECWidget]):
self.widgets = value
def hide_title_bar(self):
"""
Hide the title bar of the dock.
@@ -219,20 +194,6 @@ class BECDock(BECWidget, Dock):
self.label.hide()
self.labelHidden = True
def show(self):
"""
Show the dock.
"""
super().show()
self.show_title_bar()
def hide(self):
"""
Hide the dock.
"""
self.hide_title_bar()
super().hide()
def show_title_bar(self):
"""
Hide the title bar of the dock.
@@ -250,6 +211,7 @@ class BECDock(BECWidget, Dock):
"""
self.orig_area.docks[title] = self.orig_area.docks.pop(self.name())
self.setTitle(title)
self._name = title
def get_widgets_positions(self) -> dict:
"""
@@ -260,7 +222,7 @@ class BECDock(BECWidget, Dock):
"""
return self.layout_manager.get_widgets_positions()
def available_widgets(
def list_eligible_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
@@ -271,29 +233,20 @@ 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
widgets = []
for dock in docks:
widgets.extend(dock.elements.keys())
return widgets
def new(
def add_widget(
self,
widget: BECWidget | str,
name: str | None = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
row=None,
col=0,
rowspan=1,
colspan=1,
shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget:
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
widget(QWidget): The widget to add.
row(int): The row to add the widget to. If None, the widget will be added to the next available row.
col(int): The column to add the widget to.
rowspan(int): The number of rows the widget should span.
@@ -306,29 +259,16 @@ class BECDock(BECWidget, Dock):
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
# 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:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str):
widget = cast(
BECWidget,
widget_handler.create_widget(
widget_type=widget,
object_name=name,
parent_dock=self,
parent_id=self.gui_id,
parent=self,
),
)
widget = widget_handler.create_widget(widget)
else:
widget.object_name = name
widget = widget
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.object_name] = widget.config
self.config.widgets[widget.gui_id] = widget.config
return widget
def move_widget(self, widget: QWidget, new_row: int, new_col: int):
@@ -354,66 +294,35 @@ class BECDock(BECWidget, Dock):
"""
self.float()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
self.parent_dock_area.delete(self.object_name)
def delete(self, widget_name: str) -> None:
def remove_widget(self, widget_rpc_id: str):
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
widget_rpc_id(str): The ID of the widget to remove.
"""
# pylint: disable=protected-access
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()}. "
f"Checking if gui_id was passed as widget_name."
)
# Try to find the widget in the RPC register, maybe the gui_id was passed as widget_name
widget = self.rpc_register.get_rpc_by_id(widget_name)
if widget is None:
logger.warning(
f"Widget not found for name or gui_id: {widget_name} in dock {self.name()}"
)
return
else:
widget = widgets[0]
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
self.layout.removeWidget(widget)
self.config.widgets.pop(widget.object_name, None)
if widget in self.widgets:
self.widgets.remove(widget)
self.config.widgets.pop(widget_rpc_id, None)
widget.close()
def delete_all(self):
def remove(self):
"""
Remove all widgets from the dock.
Remove the dock from the parent dock area.
"""
for widget in self.widgets:
self.delete(widget.object_name)
# self.cleanup()
self.parent_dock_area.remove_dock(self.name())
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
# # FIXME Cleanup might be called twice
try:
logger.info(f"Cleaning up dock {self.name()}")
self.label.close()
self.label.deleteLater()
except Exception as e:
logger.error(f"Error while closing dock label: {e}")
# Remove the dock from the parent dock area
if self.parent_dock_area:
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
self.parent_dock_area.config.docks.pop(self.name(), None)
self.delete_all()
for widget in self.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
self.widgets.clear()
self.label.close()
self.label.deleteLater()
super().cleanup()
def close(self):
@@ -423,15 +332,4 @@ class BECDock(BECWidget, Dock):
"""
self.cleanup()
super().close()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication([])
dock = BECDock(name="dock")
dock.show()
app.exec_()
sys.exit(app.exec_())
self.parent_dock_area.dock_area.docks.pop(self.name(), None)

View File

@@ -1,47 +1,38 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
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
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QPainter, QPaintEvent
from qtpy.QtWidgets import QApplication, QSizePolicy, QVBoxLayout, QWidget
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import (
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
ExpandableMenuAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
)
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.dock.dock import BECDock, DockConfig
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
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
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
from bec_widgets.widgets.plots.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.cli.auto_updates import AutoUpdates
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
@@ -53,23 +44,21 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"new",
"show",
"hide",
"selected_device",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"save_state",
"remove_dock",
"restore_state",
"add_dock",
"clear_all",
"detach_dock",
"attach_all",
"save_state",
"selected_device",
"selected_device.setter",
"restore_state",
"_get_all_rpc",
"temp_areas",
"show",
"hide",
"delete",
]
def __init__(
@@ -78,8 +67,6 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
object_name: str = None,
**kwargs,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
@@ -87,49 +74,37 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(
parent=parent,
object_name=object_name,
client=client,
gui_id=gui_id,
config=config,
**kwargs,
)
self._parent = parent # TODO probably not needed
super().__init__(client=client, config=config, gui_id=gui_id)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
self.auto_update: AutoUpdates | None = None
self._auto_update_selected_device: str | None = None
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 ",
actions={
"waveform": MaterialIconAction(
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
icon_name=BECWaveformWidget.ICON_NAME,
tooltip="Add Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
icon_name=MultiWaveform.ICON_NAME,
icon_name=BECMultiWaveformWidget.ICON_NAME,
tooltip="Add Multi Waveform",
filled=True,
),
"image": MaterialIconAction(
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
icon_name=BECMotorMapWidget.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
),
},
),
@@ -187,7 +162,7 @@ class BECDockArea(BECWidget, QWidget):
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
self.toolbar.addWidget(self.dark_mode_button)
self.toolbar.addWidget(DarkModeButton(toolbar=True))
self._hook_toolbar()
def minimumSizeHint(self):
@@ -196,44 +171,41 @@ class BECDockArea(BECWidget, QWidget):
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Waveform")
)
self.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScatterWaveform")
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MultiWaveform")
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="Image")
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
lambda: self.add_dock(widget="BECQueue", prefix="queue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
)
# Icons
@@ -241,13 +213,6 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["save_state"].action.triggered.connect(self.save_state)
self.toolbar.widgets["restore_state"].action.triggered.connect(self.restore_state)
@SafeSlot()
def _create_widget_from_toolbar(self, widget_name: str) -> None:
# Run with RPC broadcast to namespace of all widgets
with RPCRegister.delayed_broadcast():
dock_name = WidgetContainerUtils.generate_unique_name(widget_name, self.panels.keys())
self.new(name=dock_name, widget=widget_name)
def paintEvent(self, event: QPaintEvent): # TODO decide if we want any default instructions
super().paintEvent(event)
if self._instructions_visible:
@@ -255,37 +220,19 @@ class BECDockArea(BECWidget, QWidget):
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
)
@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
def set_auto_update(self, auto_update_cls: type[AutoUpdates]) -> None:
"""
Set the auto update object for the dock area.
Args:
auto_update(AutoUpdates): The auto update object.
"""
self.auto_update = auto_update_cls(self)
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]:
@@ -298,17 +245,7 @@ class BECDockArea(BECWidget, QWidget):
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value) # This can not work can it?
@property
def panel_list(self) -> list[BECDock]:
"""
Get the docks in the dock area.
Returns:
list: The docks in the dock area.
"""
return list(self.dock_area.docks.values())
self.dock_area.docks = WeakValueDictionary(value)
@property
def temp_areas(self) -> list:
@@ -352,17 +289,36 @@ class BECDockArea(BECWidget, QWidget):
self.config.docks_state = last_state
return last_state
def remove_dock(self, name: str):
"""
Remove a dock by name and ensure it is properly closed and cleaned up.
Args:
name(str): The name of the dock to remove.
"""
dock = self.dock_area.docks.pop(name, None)
self.config.docks.pop(name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {name} does not exist.")
@SafeSlot(popup_error=True)
def new(
def add_dock(
self,
name: str | None = None,
widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
row: int | None = None,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
@@ -372,11 +328,12 @@ class BECDockArea(BECWidget, QWidget):
Args:
name(str): The name of the dock to be displayed and for further references. Has to be unique.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
position(Literal["bottom", "top", "left", "right", "above", "below"]): The position of the dock.
relative_to(BECDock): The dock to which the new dock should be added relative to.
closable(bool): Whether the dock is closable.
floating(bool): Whether the dock is detached after creating.
prefix(str): The prefix for the dock name if no name is provided.
widget(str|QWidget|None): The widget to be added to the dock. While using RPC, only BEC RPC widgets from RPCWidgetHandler are allowed.
row(int): The row of the added widget.
col(int): The column of the added widget.
rowspan(int): The rowspan of the added widget.
@@ -385,29 +342,21 @@ class BECDockArea(BECWidget, QWidget):
Returns:
BECDock: The created dock.
"""
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.object_name} and id {self.gui_id}."
)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
if name is None:
name = WidgetContainerUtils.generate_unique_widget_id(
container=self.dock_area.docks, prefix=prefix
)
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,
)
if name in set(self.dock_area.docks.keys()):
raise ValueError(f"Dock with name {name} already exists.")
if position is None:
position = "bottom"
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.config.docks[name] = dock.config
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
@@ -416,11 +365,10 @@ class BECDockArea(BECWidget, QWidget):
for dock in self.dock_area.docks.values():
dock.show_title_bar()
if widget is not None:
# Check if widget name exists.
dock.new(
widget=widget, name=widget_name, row=row, col=col, rowspan=rowspan, colspan=colspan
)
if widget is not None and isinstance(widget, str):
dock.add_widget(widget=widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
elif widget is not None and isinstance(widget, QWidget):
dock.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
if (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
@@ -458,30 +406,49 @@ class BECDockArea(BECWidget, QWidget):
Remove a temporary area from the dock area.
This is a patched method of pyqtgraph's removeTempArea
"""
if area not in self.dock_area.tempAreas:
# FIXME add some context for the logging, I am not sure which object is passed.
# It looks like a pyqtgraph.DockArea
logger.info(f"Attempted to remove dock_area, but was not floating.")
return
self.dock_area.tempAreas.remove(area)
area.window().close()
area.window().deleteLater()
def clear_all(self):
"""
Close all docks and remove all temp areas.
"""
self.attach_all()
for dock in dict(self.dock_area.docks).values():
dock.remove()
self.dock_area.docks.clear()
def cleanup(self):
"""
Cleanup the dock area.
"""
if self.auto_update:
self.auto_update.enabled = False
self.delete_all()
self.clear_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()
def closeEvent(self, event):
if self.parent() is None:
# we are at top-level (independent window)
if self.isVisible():
# we are visible => user clicked on [X]
# (when closeEvent is called from shutdown procedure,
# everything is hidden first)
# so, let's ignore "close", and do hide instead
event.ignore()
self.setVisible(False)
def close(self):
"""
Close the dock area and cleanup.
Has to be implemented to overwrite pyqtgraph event accept in Container close.
"""
self.cleanup()
super().close()
def show(self):
"""Show all windows including floating docks."""
super().show()
@@ -500,54 +467,18 @@ class BECDockArea(BECWidget, QWidget):
continue
docks.window().hide()
def delete_all(self) -> None:
"""
Delete all docks.
"""
self.attach_all()
for dock_name in self.panels.keys():
self.delete(dock_name)
def delete(self, dock_name: str):
"""
Delete a dock by name.
Args:
dock_name(str): The name of the dock to delete.
"""
dock = self.dock_area.docks.pop(dock_name, None)
self.config.docks.pop(dock_name, None)
if dock:
dock.close()
dock.deleteLater()
if len(self.dock_area.docks) <= 1:
for dock in self.dock_area.docks.values():
dock.hide_title_bar()
else:
raise ValueError(f"Dock with name {dock_name} does not exist.")
# self._broadcast_update()
def remove(self) -> None:
"""Remove the dock area."""
self.close()
def delete(self):
self.hide()
self.deleteLater()
if __name__ == "__main__": # pragma: no cover
import sys
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
from bec_widgets.utils.colors import set_theme
app = QApplication([])
set_theme("auto")
dock_area = BECDockArea()
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="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

@@ -0,0 +1 @@
from .figure import BECFigure, FigureConfig

View File

@@ -0,0 +1,796 @@
# pylint: disable = no-name-in-module,missing-module-docstring
from __future__ import annotations
import uuid
from collections import defaultdict
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from typeguard import typechecked
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import (
BECMotorMap,
MotorMapConfig,
)
from bec_widgets.widgets.containers.figure.plots.multi_waveform.multi_waveform import (
BECMultiWaveform,
BECMultiWaveformConfig,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import (
BECWaveform,
Waveform1DConfig,
)
logger = bec_logger.logger
class FigureConfig(ConnectionConfig):
"""Configuration for BECFigure. Inheriting from ConnectionConfig widget_class and gui_id"""
theme: Literal["dark", "light"] = Field("dark", description="The theme of the figure widget.")
num_cols: int = Field(1, description="The number of columns in the figure widget.")
num_rows: int = Field(1, description="The number of rows in the figure widget.")
widgets: dict[str, Waveform1DConfig | ImageConfig | MotorMapConfig | SubplotConfig] = Field(
{}, description="The list of widgets to be added to the figure widget."
)
@field_validator("widgets", mode="before")
@classmethod
def validate_widgets(cls, v):
"""Validate the widgets configuration."""
widget_class_map = {
"BECWaveform": Waveform1DConfig,
"BECImageShow": ImageConfig,
"BECMotorMap": MotorMapConfig,
}
validated_widgets = {}
for key, widget_config in v.items():
if "widget_class" not in widget_config:
raise ValueError(f"Widget config for {key} does not contain 'widget_class'.")
widget_class = widget_config["widget_class"]
if widget_class not in widget_class_map:
raise ValueError(f"Unknown widget_class '{widget_class}' for widget '{key}'.")
config_class = widget_class_map[widget_class]
validated_widgets[key] = config_class(**widget_config)
return validated_widgets
class WidgetHandler:
"""Factory for creating and configuring BEC widgets for BECFigure."""
def __init__(self):
self.widget_factory = {
"BECPlotBase": (BECPlotBase, SubplotConfig),
"BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig),
"BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
}
def create_widget(
self,
widget_type: str,
widget_id: str,
parent_figure,
parent_id: str,
config: dict = None,
**axis_kwargs,
) -> BECPlotBase:
"""
Create and configure a widget based on its type.
Args:
widget_type (str): The type of the widget to create.
widget_id (str): Unique identifier for the widget.
parent_id (str): Identifier of the parent figure.
config (dict, optional): Additional configuration for the widget.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECPlotBase: The created and configured widget instance.
"""
entry = self.widget_factory.get(widget_type)
if not entry:
raise ValueError(f"Unsupported widget type: {widget_type}")
widget_class, config_class = entry
if config is not None and isinstance(config, config_class):
config = config.model_dump()
widget_config_dict = {
"widget_class": widget_class.__name__,
"parent_id": parent_id,
"gui_id": widget_id,
**(config if config is not None else {}),
}
widget_config = config_class(**widget_config_dict)
widget = widget_class(
config=widget_config, parent_figure=parent_figure, client=parent_figure.client
)
if axis_kwargs:
widget.set(**axis_kwargs)
return widget
class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"_get_all_rpc",
"axes",
"widgets",
"plot",
"image",
"motor_map",
"remove",
"change_layout",
"change_theme",
"export",
"clear_all",
"widget_list",
]
subplot_map = {
"PlotBase": BECPlotBase,
"BECWaveform": BECWaveform,
"BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap,
"BECMultiWaveform": BECMultiWaveform,
}
widget_method_map = {
"BECWaveform": "plot",
"BECImageShow": "image",
"BECMotorMap": "motor_map",
"BECMultiWaveform": "multi_waveform",
}
clean_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
config: Optional[FigureConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
) -> None:
if config is None:
config = FigureConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = FigureConfig(**config)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
pg.GraphicsLayoutWidget.__init__(self, parent)
self.widget_handler = WidgetHandler()
# Widget container to reference widgets by 'widget_id'
self._widgets = defaultdict(dict)
# Container to keep track of the grid
self.grid = []
# Create config and apply it
self.apply_config(config)
def __getitem__(self, key: tuple | str):
if isinstance(key, tuple) and len(key) == 2:
return self.axes(*key)
if isinstance(key, str):
widget = self._widgets.get(key)
if widget is None:
raise KeyError(f"No widget with ID {key}")
return self._widgets.get(key)
else:
raise TypeError(
"Key must be a string (widget id) or a tuple of two integers (grid coordinates)"
)
def apply_config(self, config: dict | FigureConfig): # ,generate_new_id: bool = False):
if isinstance(config, dict):
try:
config = FigureConfig(**config)
except ValidationError as e:
logger.error(f"Error in applying config: {e}")
return
self.config = config
# widget_config has to be reset for not have each widget config twice when added to the figure
widget_configs = list(self.config.widgets.values())
self.config.widgets = {}
for widget_config in widget_configs:
getattr(self, self.widget_method_map[widget_config.widget_class])(
config=widget_config.model_dump(), row=widget_config.row, col=widget_config.col
)
@property
def widget_list(self) -> list[BECPlotBase]:
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
axes = [value for value in self._widgets.values() if isinstance(value, BECPlotBase)]
return axes
@widget_list.setter
def widget_list(self, value: list[BECPlotBase]):
"""
Access all widget in BECFigure as a list
Returns:
list[BECPlotBase]: List of all widgets in the figure.
"""
self._axes = value
@property
def widgets(self) -> dict:
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
return self._widgets
@widgets.setter
def widgets(self, value: dict):
"""
All widgets within the figure with gui ids as keys.
Returns:
dict: All widgets within the figure.
"""
self._widgets = value
def export(self):
"""Export the plot widget."""
try:
plot_item = self.widget_list[0]
except Exception as exc:
raise ValueError("No plot widget available to export.") from exc
scene = plot_item.scene()
scene.contextMenuItem = plot_item
scene.showExportDialog()
@typechecked
def plot(
self,
arg1: list | np.ndarray | str | None = None,
y: list | np.ndarray | None = None,
x: list | np.ndarray | None = None,
x_name: str | None = None,
y_name: str | None = None,
z_name: str | None = None,
x_entry: str | None = None,
y_entry: str | None = None,
z_entry: str | None = None,
color: str | None = None,
color_map_z: str | None = "magma",
label: str | None = None,
validate: bool = True,
new: bool = False,
row: int | None = None,
col: int | None = None,
dap: str | None = None,
config: dict | None = None, # TODO make logic more transparent
**axis_kwargs,
) -> BECWaveform:
"""
Add a 1D waveform plot to the figure. Always access the first waveform widget in the figure.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data, y data, or y_name.
y(list | np.ndarray): Custom y data to plot.
x(list | np.ndarray): Custom x data to plot.
x_name(str): The name of the device for the x-axis.
y_name(str): The name of the device for the y-axis.
z_name(str): The name of the device for the z-axis.
x_entry(str): The name of the entry for the x-axis.
y_entry(str): The name of the entry for the y-axis.
z_entry(str): The name of the entry for the z-axis.
color(str): The color of the curve.
color_map_z(str): The color map to use for the z-axis.
label(str): The label of the curve.
validate(bool): If True, validate the device names and entries.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
dap(str): The DAP model to use for the curve.
config(dict): Recreates the whole BECWaveform widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECWaveform: The waveform plot widget.
"""
waveform = self.subplot_factory(
widget_type="BECWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return waveform
if arg1 is not None or y_name is not None or (y is not None and x is not None):
waveform.plot(
arg1=arg1,
y=y,
x=x,
x_name=x_name,
y_name=y_name,
z_name=z_name,
x_entry=x_entry,
y_entry=y_entry,
z_entry=z_entry,
color=color,
color_map_z=color_map_z,
label=label,
validate=validate,
dap=dap,
)
return waveform
def _init_image(
self,
image,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
) -> BECImageShow:
"""
Configure the image based on the provided parameters.
Args:
image (BECImageShow): The image to configure.
monitor (str): The name of the monitor to display.
color_bar (Literal["simple","full"]): The type of color bar to display.
color_map (str): The color map to use for the image.
data (np.ndarray): Custom data to display.
"""
if monitor is not None and data is None:
image.image(
monitor=monitor,
monitor_type=monitor_type,
color_map=color_map,
vrange=vrange,
color_bar=color_bar,
)
elif data is not None and monitor is None:
image.add_custom_image(
name="custom", data=data, color_map=color_map, vrange=vrange, color_bar=color_bar
)
elif data is None and monitor is None:
# Setting appearance
if vrange is not None:
image.set_vrange(vmin=vrange[0], vmax=vrange[1])
if color_map is not None:
image.set_color_map(color_map)
else:
raise ValueError("Invalid input. Provide either monitor name or custom data.")
return image
def image(
self,
monitor: str = None,
monitor_type: Literal["1d", "2d"] = "2d",
color_bar: Literal["simple", "full"] = "full",
color_map: str = "magma",
data: np.ndarray = None,
vrange: tuple[float, float] = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
) -> BECImageShow:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECImageShow: The image widget.
"""
image = self.subplot_factory(
widget_type="BECImageShow", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return image
image = self._init_image(
image=image,
monitor=monitor,
monitor_type=monitor_type,
color_bar=color_bar,
color_map=color_map,
data=data,
vrange=vrange,
)
return image
def motor_map(
self,
motor_x: str = None,
motor_y: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
) -> BECMotorMap:
"""
Add a motor map to the figure. Always access the first motor map widget in the figure.
Args:
motor_x(str): The name of the motor for the X axis.
motor_y(str): The name of the motor for the Y axis.
new(bool): If True, create a new plot instead of using the first plot.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Recreates the whole BECImageShow widget from provided configuration.
**axis_kwargs: Additional axis properties to set on the widget after creation.
Returns:
BECMotorMap: The motor map widget.
"""
motor_map = self.subplot_factory(
widget_type="BECMotorMap", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return motor_map
if motor_x is not None and motor_y is not None:
motor_map.change_motors(motor_x, motor_y)
return motor_map
def multi_waveform(
self,
monitor: str = None,
new: bool = False,
row: int | None = None,
col: int | None = None,
config: dict | None = None,
**axis_kwargs,
):
multi_waveform = self.subplot_factory(
widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
)
if config is not None:
return multi_waveform
multi_waveform.set_monitor(monitor)
return multi_waveform
def subplot_factory(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
row: int = None,
col: int = None,
config=None,
new: bool = False,
**axis_kwargs,
) -> BECPlotBase:
# Case 1 - config provided, new plot, possible to define coordinates
if config is not None:
widget_cls = config["widget_class"]
if widget_cls != widget_type:
raise ValueError(
f"Widget type '{widget_type}' does not match the provided configuration ({widget_cls})."
)
widget = self.add_widget(
widget_type=widget_type, config=config, row=row, col=col, **axis_kwargs
)
return widget
# Case 2 - find first plot or create first plot if no plot available, no config provided, no coordinates
if new is False and (row is None or col is None):
widget = WidgetContainerUtils.find_first_widget_by_class(
self._widgets, self.subplot_map[widget_type], can_fail=True
)
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, **axis_kwargs)
return widget
# Case 3 - modifying existing plot wit coordinates provided
if new is False and (row is not None and col is not None):
try:
widget = self.axes(row, col)
except ValueError:
widget = None
if widget is not None:
if axis_kwargs:
widget.set(**axis_kwargs)
else:
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
# Case 4 - no previous plot or new plot, no config provided, possible to define coordinates
widget = self.add_widget(widget_type=widget_type, row=row, col=col, **axis_kwargs)
return widget
def add_widget(
self,
widget_type: Literal[
"BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
widget_id: str = None,
row: int = None,
col: int = None,
config=None,
**axis_kwargs,
) -> BECPlotBase:
"""
Add a widget to the figure at the specified position.
Args:
widget_type(Literal["PlotBase","Waveform1D"]): The type of the widget to add.
widget_id(str): The unique identifier of the widget. If not provided, a unique ID will be generated.
row(int): The row coordinate of the widget in the figure. If not provided, the next empty row will be used.
col(int): The column coordinate of the widget in the figure. If not provided, the next empty column will be used.
config(dict): Additional configuration for the widget.
**axis_kwargs(dict): Additional axis properties to set on the widget after creation.
"""
if not widget_id:
widget_id = str(uuid.uuid4())
if widget_id in self._widgets:
raise ValueError(f"Widget with ID '{widget_id}' already exists.")
# Check if position is occupied
if row is not None and col is not None:
if self.getItem(row, col):
raise ValueError(f"Position at row {row} and column {col} is already occupied.")
else:
row, col = self._find_next_empty_position()
widget = self.widget_handler.create_widget(
widget_type=widget_type,
widget_id=widget_id,
parent_figure=self,
parent_id=self.gui_id,
config=config,
**axis_kwargs,
)
widget.set_gui_id(widget_id)
widget.config.row = row
widget.config.col = col
# Add widget to the figure
self.addItem(widget, row=row, col=col)
# Update num_cols and num_rows based on the added widget
self.config.num_rows = max(self.config.num_rows, row + 1)
self.config.num_cols = max(self.config.num_cols, col + 1)
# Saving config for future referencing
self.config.widgets[widget_id] = widget.config
self._widgets[widget_id] = widget
# Reflect the grid coordinates
self._change_grid(widget_id, row, col)
return widget
def remove(
self,
row: int = None,
col: int = None,
widget_id: str = None,
coordinates: tuple[int, int] = None,
) -> None:
"""
Remove a widget from the figure. Can be removed by its unique identifier or by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
widget_id(str): The unique identifier of the widget to remove.
coordinates(tuple[int, int], optional): The coordinates of the widget to remove.
"""
if widget_id:
self._remove_by_id(widget_id)
elif row is not None and col is not None:
self._remove_by_coordinates(row, col)
elif coordinates:
self._remove_by_coordinates(*coordinates)
else:
raise ValueError("Must provide either widget_id or coordinates for removal.")
def change_theme(self, theme: Literal["dark", "light"]) -> None:
"""
Change the theme of the figure widget.
Args:
theme(Literal["dark","light"]): The theme to set for the figure widget.
"""
self.config.theme = theme
apply_theme(theme)
for plot in self.widget_list:
plot.set_x_label(plot.plot_item.getAxis("bottom").label.toPlainText())
plot.set_y_label(plot.plot_item.getAxis("left").label.toPlainText())
if plot.plot_item.titleLabel.text:
plot.set_title(plot.plot_item.titleLabel.text)
plot.set_legend_label_size()
def _remove_by_coordinates(self, row: int, col: int) -> None:
"""
Remove a widget from the figure by its coordinates.
Args:
row(int): The row coordinate of the widget to remove.
col(int): The column coordinate of the widget to remove.
"""
widget = self.axes(row, col)
if widget:
widget_id = widget.config.gui_id
if widget_id in self._widgets:
self._remove_by_id(widget_id)
def _remove_by_id(self, widget_id: str) -> None:
"""
Remove a widget from the figure by its unique identifier.
Args:
widget_id(str): The unique identifier of the widget to remove.
"""
if widget_id in self._widgets:
widget = self._widgets.pop(widget_id)
widget.cleanup_pyqtgraph()
widget.cleanup()
self.removeItem(widget)
self.grid[widget.config.row][widget.config.col] = None
self._reindex_grid()
if widget_id in self.config.widgets:
self.config.widgets.pop(widget_id)
widget.deleteLater()
else:
raise ValueError(f"Widget with ID '{widget_id}' does not exist.")
def axes(self, row: int, col: int) -> BECPlotBase:
"""
Get widget by its coordinates in the figure.
Args:
row(int): the row coordinate
col(int): the column coordinate
Returns:
BECPlotBase: the widget at the given coordinates
"""
widget = self.getItem(row, col)
if widget is None:
raise ValueError(f"No widget at coordinates ({row}, {col})")
return widget
def _find_next_empty_position(self):
"""Find the next empty position (new row) in the figure."""
row, col = 0, 0
while self.getItem(row, col):
row += 1
return row, col
def _change_grid(self, widget_id: str, row: int, col: int):
"""
Change the grid to reflect the new position of the widget.
Args:
widget_id(str): The unique identifier of the widget.
row(int): The new row coordinate of the widget in the figure.
col(int): The new column coordinate of the widget in the figure.
"""
while len(self.grid) <= row:
self.grid.append([])
row = self.grid[row]
while len(row) <= col:
row.append(None)
row[col] = widget_id
def _reindex_grid(self):
"""Reindex the grid to remove empty rows and columns."""
new_grid = []
for row in self.grid:
new_row = [widget for widget in row if widget is not None]
if new_row:
new_grid.append(new_row)
#
# Update the config of each object to reflect its new position
for row_idx, row in enumerate(new_grid):
for col_idx, widget in enumerate(row):
self._widgets[widget].config.row, self._widgets[widget].config.col = (
row_idx,
col_idx,
)
self.grid = new_grid
self._replot_layout()
def _replot_layout(self):
"""Replot the layout based on the current grid configuration."""
self.clear()
for row_idx, row in enumerate(self.grid):
for col_idx, widget in enumerate(row):
self.addItem(self._widgets[widget], row=row_idx, col=col_idx)
def change_layout(self, max_columns=None, max_rows=None):
"""
Reshuffle the layout of the figure to adjust to a new number of max_columns or max_rows.
If both max_columns and max_rows are provided, max_rows is ignored.
Args:
max_columns (Optional[int]): The new maximum number of columns in the figure.
max_rows (Optional[int]): The new maximum number of rows in the figure.
"""
# Calculate total number of widgets
total_widgets = len(self._widgets)
if max_columns:
# Calculate the required number of rows based on max_columns
required_rows = (total_widgets + max_columns - 1) // max_columns
new_grid = [[None for _ in range(max_columns)] for _ in range(required_rows)]
elif max_rows:
# Calculate the required number of columns based on max_rows
required_columns = (total_widgets + max_rows - 1) // max_rows
new_grid = [[None for _ in range(required_columns)] for _ in range(max_rows)]
else:
# If neither max_columns nor max_rows is specified, just return without changing the layout
return
# Populate the new grid with widgets' IDs
current_idx = 0
for widget_id in self._widgets:
row = current_idx // len(new_grid[0])
col = current_idx % len(new_grid[0])
new_grid[row][col] = widget_id
current_idx += 1
self.config.num_rows = row
self.config.num_cols = col
# Update widgets' positions and replot them according to the new grid
self.grid = new_grid
self._reindex_grid() # This method should be updated to handle reshuffling correctly
self._replot_layout() # Assumes this method re-adds widgets to the layout based on self.grid
def clear_all(self):
"""Clear all widgets from the figure and reset to default state"""
for widget in list(self._widgets.values()):
widget.remove()
self._widgets.clear()
self.grid = []
theme = self.config.theme
self.config = FigureConfig(
widget_class=self.__class__.__name__, gui_id=self.gui_id, theme=theme
)
def cleanup_pyqtgraph_all_widgets(self):
"""Clean up the pyqtgraph widget."""
for widget in self.widget_list:
widget.cleanup_pyqtgraph()
def cleanup(self):
"""Close the figure widget."""
self.cleanup_pyqtgraph_all_widgets()

View File

@@ -0,0 +1,91 @@
import os
from qtpy.QtWidgets import QVBoxLayout
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class AxisSettings(SettingWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader().load_ui(os.path.join(current_path, "axis_settings.ui"), self)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
# Hardcoded values for best appearance
self.setMinimumHeight(280)
self.setMaximumHeight(280)
self.resize(380, 280)
@Slot(dict)
def display_current_settings(self, axis_config: dict):
if axis_config == {}:
return
# Top Box
WidgetIO.set_value(self.ui.plot_title, axis_config["title"])
self.ui.switch_outer_axes.checked = axis_config["outer_axes"]
# X Axis Box
WidgetIO.set_value(self.ui.x_label, axis_config["x_label"])
WidgetIO.set_value(self.ui.x_scale, axis_config["x_scale"])
WidgetIO.set_value(self.ui.x_grid, axis_config["x_grid"])
if axis_config["x_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.x_max, axis_config["x_lim"][1])
WidgetIO.set_value(self.ui.x_min, axis_config["x_lim"][0])
WidgetIO.set_value(self.ui.x_max, axis_config["x_lim"][1])
if axis_config["x_lim"] is None:
x_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[0]
WidgetIO.set_value(self.ui.x_min, x_range[0])
WidgetIO.set_value(self.ui.x_max, x_range[1])
# Y Axis Box
WidgetIO.set_value(self.ui.y_label, axis_config["y_label"])
WidgetIO.set_value(self.ui.y_scale, axis_config["y_scale"])
WidgetIO.set_value(self.ui.y_grid, axis_config["y_grid"])
if axis_config["y_lim"] is not None:
WidgetIO.check_and_adjust_limits(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.check_and_adjust_limits(self.ui.y_max, axis_config["y_lim"][1])
WidgetIO.set_value(self.ui.y_min, axis_config["y_lim"][0])
WidgetIO.set_value(self.ui.y_max, axis_config["y_lim"][1])
if axis_config["y_lim"] is None:
y_range = self.target_widget.fig.widget_list[0].plot_item.viewRange()[1]
WidgetIO.set_value(self.ui.y_min, y_range[0])
WidgetIO.set_value(self.ui.y_max, y_range[1])
@Slot()
def accept_changes(self):
title = WidgetIO.get_value(self.ui.plot_title)
outer_axes = self.ui.switch_outer_axes.checked
# X Axis
x_label = WidgetIO.get_value(self.ui.x_label)
x_scale = self.ui.x_scale.currentText()
x_grid = WidgetIO.get_value(self.ui.x_grid)
x_lim = (WidgetIO.get_value(self.ui.x_min), WidgetIO.get_value(self.ui.x_max))
# Y Axis
y_label = WidgetIO.get_value(self.ui.y_label)
y_scale = self.ui.y_scale.currentText()
y_grid = WidgetIO.get_value(self.ui.y_grid)
y_lim = (WidgetIO.get_value(self.ui.y_min), WidgetIO.get_value(self.ui.y_max))
self.target_widget.set(
title=title,
x_label=x_label,
x_scale=x_scale,
x_lim=x_lim,
y_label=y_label,
y_scale=y_scale,
y_lim=y_lim,
)
self.target_widget.set_grid(x_grid, y_grid)
self.target_widget.set_outer_axes(outer_axes)

View File

@@ -0,0 +1,256 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>427</width>
<height>270</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>250</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>278</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0" colspan="2">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="plot_title_label">
<property name="text">
<string>Plot Title</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="plot_title"/>
</item>
</layout>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_outer_axes">
<property name="text">
<string>Outer Axes</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="y_axis_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_5">
<item row="3" column="2">
<widget class="QComboBox" name="y_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="y_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="y_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="y_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="y_label"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="y_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="y_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="y_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="y_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="y_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="2" column="0">
<widget class="QGroupBox" name="x_axis_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QGridLayout" name="gridLayout_4">
<item row="3" column="0">
<widget class="QLabel" name="x_scale_label">
<property name="text">
<string>Scale</string>
</property>
</widget>
</item>
<item row="1" column="2">
<widget class="QDoubleSpinBox" name="x_min">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="x_min_label">
<property name="text">
<string>Min</string>
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QDoubleSpinBox" name="x_max">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
<property name="minimum">
<double>-9999.000000000000000</double>
</property>
<property name="maximum">
<double>9999.000000000000000</double>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QComboBox" name="x_scale">
<item>
<property name="text">
<string>linear</string>
</property>
</item>
<item>
<property name="text">
<string>log</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="x_max_label">
<property name="text">
<string>Max</string>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QLineEdit" name="x_label"/>
</item>
<item row="0" column="0">
<widget class="QLabel" name="x_label_label">
<property name="text">
<string>Label</string>
</property>
</widget>
</item>
<item row="4" column="2">
<widget class="QCheckBox" name="x_grid">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="x_grid_label">
<property name="text">
<string>Grid</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="1" column="1">
<widget class="ToggleSwitch" name="switch_outer_axes">
<property name="checked" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,772 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Literal, Optional
import numpy as np
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError
from qtpy.QtCore import QThread, Slot
from qtpy.QtWidgets import QWidget
# from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import EntryValidator
from bec_widgets.widgets.containers.figure.plots.image.image_item import (
BECImageItem,
ImageItemConfig,
)
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageProcessor,
ImageStats,
ProcessorWorker,
)
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class ImageConfig(SubplotConfig):
images: dict[str, ImageItemConfig] = Field(
{},
description="The configuration of the images. The key is the name of the image (source).",
)
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"add_image_by_config",
"image",
"add_custom_image",
"set_vrange",
"set_color_map",
"set_autorange",
"set_autorange_mode",
"set_monitor",
"set_processing",
"set_image_properties",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
"images",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[ImageConfig] = None,
client=None,
gui_id: Optional[str] = None,
single_image: bool = True,
):
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.single_image = single_image
self.image_type = "device_monitor_2d"
self.scan_id = None
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
self._images = defaultdict(dict)
self.apply_config(self.config)
self.processor = ImageProcessor()
self.use_threading = False # TODO WILL be moved to the init method and to figure method
def _create_thread_worker(self, device: str, image: np.ndarray):
thread = QThread()
worker = ProcessorWorker(self.processor)
worker.moveToThread(thread)
# Connect signals and slots
thread.started.connect(lambda: worker.process_image(device, image))
worker.processed.connect(self.update_image)
worker.stats.connect(self.update_vrange)
worker.finished.connect(thread.quit)
worker.finished.connect(thread.wait)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.start()
def find_image_by_monitor(self, item_id: str) -> BECImageItem:
"""
Find the image item by its gui_id.
Args:
item_id(str): The gui_id of the widget.
Returns:
BECImageItem: The widget with the given gui_id.
"""
for source, images in self._images.items():
for key, value in images.items():
if key == item_id and isinstance(value, BECImageItem):
return value
elif isinstance(value, dict):
result = self.find_image_by_monitor(item_id)
if result is not None:
return result
def apply_config(self, config: dict | SubplotConfig):
"""
Apply the configuration to the 1D waveform widget.
Args:
config(dict|SubplotConfig): Configuration settings.
replot_last_scan(bool, optional): If True, replot the last scan. Defaults to False.
"""
if isinstance(config, dict):
try:
config = ImageConfig(**config)
except ValidationError as e:
logger.error(f"Validation error when applying config to BECImageShow: {e}")
return
self.config = config
self.plot_item.clear()
self.apply_axis_config()
self._images = defaultdict(dict)
for image_id, image_config in config.images.items():
self.add_image_by_config(image_config)
def change_gui_id(self, new_gui_id: str):
"""
Change the GUI ID of the image widget and update the parent_id in all associated curves.
Args:
new_gui_id (str): The new GUI ID to be set for the image widget.
"""
self.gui_id = new_gui_id
self.config.gui_id = new_gui_id
for source, images in self._images.items():
for id, image_item in images.items():
image_item.config.parent_id = new_gui_id
def add_image_by_config(self, config: ImageItemConfig | dict) -> BECImageItem:
"""
Add an image to the widget by configuration.
Args:
config(ImageItemConfig|dict): The configuration of the image.
Returns:
BECImageItem: The image object.
"""
if isinstance(config, dict):
config = ImageItemConfig(**config)
config.parent_id = self.gui_id
name = config.monitor if config.monitor is not None else config.gui_id
image = self._add_image_object(source=config.source, name=name, config=config)
return image
def get_image_config(self, image_id, dict_output: bool = True) -> ImageItemConfig | dict:
"""
Get the configuration of the image.
Args:
image_id(str): The ID of the image.
dict_output(bool): Whether to return the configuration as a dictionary. Defaults to True.
Returns:
ImageItemConfig|dict: The configuration of the image.
"""
for source, images in self._images.items():
for id, image in images.items():
if id == image_id:
if dict_output:
return image.config.dict()
else:
return image.config # TODO check if this works
@property
def images(self) -> list[BECImageItem]:
"""
Get the list of images.
Returns:
list[BECImageItem]: The list of images.
"""
images = []
for source, images_dict in self._images.items():
for id, image in images_dict.items():
images.append(image)
return images
@images.setter
def images(self, value: dict[str, dict[str, BECImageItem]]):
"""
Set the images from a dictionary.
Args:
value (dict[str, dict[str, BECImageItem]]): The images to set, organized by source and id.
"""
self._images = value
def get_image_dict(self) -> dict[str, dict[str, BECImageItem]]:
"""
Get all images.
Returns:
dict[str, dict[str, BECImageItem]]: The dictionary of images.
"""
return self._images
def image(
self,
monitor: str,
monitor_type: Literal["1d", "2d"] = "2d",
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
) -> BECImageItem:
"""
Add an image to the figure. Always access the first image widget in the figure.
Args:
monitor(str): The name of the monitor to display.
monitor_type(Literal["1d","2d"]): The type of monitor to display.
color_bar(Literal["simple","full"]): The type of color bar to display.
color_map(str): The color map to use for the image.
data(np.ndarray): Custom data to display.
vrange(tuple[float, float]): The range of values to display.
Returns:
BECImageItem: The image item.
"""
if monitor_type == "1d":
image_source = "device_monitor_1d"
self.image_type = "device_monitor_1d"
elif monitor_type == "2d":
image_source = "device_monitor_2d"
self.image_type = "device_monitor_2d"
image_exits = self._check_image_id(monitor, self._images)
if image_exits:
# raise ValueError(
# f"Monitor with ID '{monitor}' already exists in widget '{self.gui_id}'."
# )
return
# monitor = self.entry_validator.validate_monitor(monitor)
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
source=image_source,
monitor=monitor,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(source=image_source, name=monitor, config=image_config)
return image
def add_custom_image(
self,
name: str,
data: Optional[np.ndarray] = None,
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
):
image_source = "custom"
image_exits = self._check_image_id(name, self._images)
if image_exits:
raise ValueError(f"Monitor with ID '{name}' already exists in widget '{self.gui_id}'.")
image_config = ImageItemConfig(
widget_class="BECImageItem",
parent_id=self.gui_id,
monitor=name,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
# post_processing=post_processing,
**kwargs,
)
image = self._add_image_object(
source=image_source, name=name, config=image_config, data=data
)
return image
def apply_setting_to_images(
self, setting_method_name: str, args: list, kwargs: dict, image_id: str = None
):
"""
Apply a setting to all images or a specific image by its ID.
Args:
setting_method_name (str): The name of the method to apply (e.g., 'set_color_map').
args (list): Positional arguments for the setting method.
kwargs (dict): Keyword arguments for the setting method.
image_id (str, optional): The ID of the specific image to apply the setting to. If None, applies to all images.
"""
if image_id:
image = self.find_image_by_monitor(image_id)
if image:
getattr(image, setting_method_name)(*args, **kwargs)
else:
for source, images in self._images.items():
for _, image in images.items():
getattr(image, setting_method_name)(*args, **kwargs)
self.refresh_image()
def set_vrange(self, vmin: float, vmax: float, name: str = None):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_vrange", args=[vmin, vmax], kwargs={}, image_id=name)
def set_color_map(self, cmap: str, name: str = None):
"""
Set the color map of the image.
If name is not specified, then set color map for all images.
Args:
cmap(str): The color map of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_color_map", args=[cmap], kwargs={}, image_id=name)
def set_autorange(self, enable: bool = False, name: str = None):
"""
Set the autoscale of the image.
Args:
enable(bool): Whether to autoscale the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange", args=[enable], kwargs={}, image_id=name)
def set_autorange_mode(self, mode: Literal["max", "mean"], name: str = None):
"""
Set the autoscale mode of the image, that decides how the vrange of the color bar is scaled.
Choose betwen 'max' -> min/max of the data, 'mean' -> mean +/- fudge_factor*std of the data (fudge_factor~2).
Args:
mode(str): The autoscale mode of the image.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_autorange_mode", args=[mode], kwargs={}, image_id=name)
def set_monitor(self, monitor: str, name: str = None):
"""
Set the monitor of the image.
If name is not specified, then set monitor for all images.
Args:
monitor(str): The name of the monitor.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_monitor", args=[monitor], kwargs={}, image_id=name)
def set_processing(self, name: str = None, **kwargs):
"""
Set the post processing of the image.
If name is not specified, then set post processing for all images.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_image_properties(self, name: str = None, **kwargs):
"""
Set the properties of the image.
Args:
name(str): The name of the image. If None, apply to all images.
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample: bool
- color_map: str
- monitor: str
- opacity: float
- vrange: tuple[int,int]
- fft: bool
- log: bool
- rot: int
- transpose: bool
"""
self.apply_setting_to_images("set", args=[], kwargs=kwargs, image_id=name)
def set_fft(self, enable: bool = False, name: str = None):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_fft", args=[enable], kwargs={}, image_id=name)
def set_log(self, enable: bool = False, name: str = None):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_log", args=[enable], kwargs={}, image_id=name)
def set_rotation(self, deg_90: int = 0, name: str = None):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_rotation", args=[deg_90], kwargs={}, image_id=name)
def set_transpose(self, enable: bool = False, name: str = None):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self.apply_setting_to_images("set_transpose", args=[enable], kwargs={}, image_id=name)
def toggle_threading(self, use_threading: bool):
"""
Toggle threading for the widgets postprocessing and updating.
Args:
use_threading(bool): Whether to use threading.
"""
self.use_threading = use_threading
if self.use_threading is False and self.thread.isRunning():
self.cleanup()
def process_image(self, device: str, image: BECImageItem, data: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device - image_id of image.
image(np.ndarray): The image data to be processed.
data(np.ndarray): The image data to be processed.
Returns:
np.ndarray: The processed image data.
"""
processing_config = image.config.processing
self.processor.set_config(processing_config)
if self.use_threading:
self._create_thread_worker(device, data)
else:
data = self.processor.process_image(data)
self.update_image(device, data)
self.update_vrange(device, self.processor.config.stats)
@Slot(dict, dict)
def on_image_update(self, msg: dict, metadata: dict):
"""
Update the image of the device monitor from bec.
Args:
msg(dict): The message from bec.
metadata(dict): The metadata of the message.
"""
data = msg["data"]
device = msg["device"]
if self.image_type == "device_monitor_1d":
image = self._images["device_monitor_1d"][device]
current_scan_id = metadata.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.reset()
self.scan_id = current_scan_id
image.image_buffer_list = []
image.max_len = 0
image_buffer = self.adjust_image_buffer(image, data)
image.raw_data = image_buffer
self.process_image(device, image, image_buffer)
elif self.image_type == "device_monitor_2d":
image = self._images["device_monitor_2d"][device]
image.raw_data = data
self.process_image(device, image, data)
def adjust_image_buffer(self, image: BECImageItem, new_data: np.ndarray) -> np.ndarray:
"""
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
Args:
image: The image object (used to store buffer list and max_len).
new_data (np.ndarray): The new incoming 1D waveform data.
Returns:
np.ndarray: The updated image buffer with adjusted shapes.
"""
new_len = new_data.shape[0]
if not hasattr(image, "image_buffer_list"):
image.image_buffer_list = []
image.max_len = 0
if new_len > image.max_len:
image.max_len = new_len
for i in range(len(image.image_buffer_list)):
wf = image.image_buffer_list[i]
pad_width = image.max_len - wf.shape[0]
if pad_width > 0:
image.image_buffer_list[i] = np.pad(
wf, (0, pad_width), mode="constant", constant_values=0
)
image.image_buffer_list.append(new_data)
else:
pad_width = image.max_len - new_len
if pad_width > 0:
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
image.image_buffer_list.append(new_data)
image_buffer = np.array(image.image_buffer_list)
return image_buffer
@Slot(str, np.ndarray)
def update_image(self, device: str, data: np.ndarray):
"""
Update the image of the device monitor.
Args:
device(str): The name of the device.
data(np.ndarray): The data to be updated.
"""
image_to_update = self._images[self.image_type][device]
image_to_update.updateImage(data, autoLevels=image_to_update.config.autorange)
@Slot(str, ImageStats)
def update_vrange(self, device: str, stats: ImageStats):
"""
Update the scaling of the image.
Args:
stats(ImageStats): The statistics of the image.
"""
image_to_update = self._images[self.image_type][device]
if image_to_update.config.autorange:
image_to_update.auto_update_vrange(stats)
def refresh_image(self):
"""
Refresh the image.
"""
for source, images in self._images.items():
for image_id, image in images.items():
data = image.raw_data
self.process_image(image_id, image, data)
def _connect_device_monitor(self, monitor: str):
"""
Connect to the device monitor.
Args:
monitor(str): The name of the monitor.
"""
image_item = self.find_image_by_monitor(monitor)
try:
previous_monitor = image_item.config.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and image_item.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(previous_monitor)
)
image_item.connected = False
if monitor and image_item.connected is False:
self.entry_validator.validate_monitor(monitor)
if self.image_type == "device_monitor_1d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
elif self.image_type == "device_monitor_2d":
self.bec_dispatcher.connect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(monitor)
)
image_item.set_monitor(monitor)
image_item.connected = True
def _add_image_object(
self, source: str, name: str, config: ImageItemConfig, data=None
) -> BECImageItem:
config.parent_id = self.gui_id
if self.single_image is True and len(self.images) > 0:
self.remove_image(0)
image = BECImageItem(config=config, parent_image=self)
self.plot_item.addItem(image)
self._images[source][name] = image
self._connect_device_monitor(config.monitor)
self.config.images[name] = config
if data is not None:
image.setImage(data)
return image
def _check_image_id(self, val: Any, dict_to_check: dict) -> bool:
"""
Check if val is in the values of the dict_to_check or in the values of the nested dictionaries.
Args:
val(Any): Value to check.
dict_to_check(dict): Dictionary to check.
Returns:
bool: True if val is in the values of the dict_to_check or in the values of the nested dictionaries, False otherwise.
"""
if val in dict_to_check.keys():
return True
for key in dict_to_check:
if isinstance(dict_to_check[key], dict):
if self._check_image_id(val, dict_to_check[key]):
return True
return False
def remove_image(self, *identifiers):
"""
Remove an image from the plot widget.
Args:
*identifiers: Identifier of the image to be removed. Can be either an integer (index) or a string (image_id).
"""
for identifier in identifiers:
if isinstance(identifier, int):
self._remove_image_by_order(identifier)
elif isinstance(identifier, str):
self._remove_image_by_id(identifier)
else:
raise ValueError(
"Each identifier must be either an integer (index) or a string (image_id)."
)
def _remove_image_by_id(self, image_id):
for source, images in self._images.items():
if image_id in images:
self._disconnect_monitor(image_id)
image = images.pop(image_id)
self.removeItem(image.color_bar)
self.plot_item.removeItem(image)
del self.config.images[image_id]
if image in self.images:
self.images.remove(image)
return
raise KeyError(f"Image with ID '{image_id}' not found.")
def _remove_image_by_order(self, N):
"""
Remove an image by its order from the plot widget.
Args:
N(int): Order of the image to be removed.
"""
if N < len(self.images):
image = self.images[N]
image_id = image.config.monitor
self._disconnect_monitor(image_id)
self.removeItem(image.color_bar)
self.plot_item.removeItem(image)
del self.config.images[image_id]
for source, images in self._images.items():
if image_id in images:
del images[image_id]
break
else:
raise IndexError(f"Image order {N} out of range.")
def _disconnect_monitor(self, image_id):
"""
Disconnect the monitor from the device.
Args:
image_id(str): The ID of the monitor.
"""
image = self.find_image_by_monitor(image_id)
if image:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(image.config.monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_2d(image.config.monitor)
)
def cleanup(self):
"""
Clean up the widget.
"""
for monitor in self._images[self.image_type]:
self.bec_dispatcher.disconnect_slot(
self.on_image_update, MessageEndpoints.device_monitor_1d(monitor)
)
self.images.clear()
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
super().cleanup_pyqtgraph()
item = self.plot_item
if not item.items:
return
cbar = item.items[0].color_bar
cbar.vb.menu.close()
cbar.vb.menu.deleteLater()
cbar.gradient.menu.close()
cbar.gradient.menu.deleteLater()
cbar.gradient.colorDialog.close()
cbar.gradient.colorDialog.deleteLater()

View File

@@ -0,0 +1,337 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.widgets.containers.figure.plots.image.image_processor import (
ImageStats,
ProcessingConfig,
)
if TYPE_CHECKING:
from bec_widgets.widgets.containers.figure.plots.image.image import BECImageShow
logger = bec_logger.logger
class ImageItemConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent plot of the image.")
monitor: Optional[str] = Field(None, description="The name of the monitor.")
source: Optional[str] = Field(None, description="The source of the curve.")
color_map: Optional[str] = Field("magma", description="The color map of the image.")
downsample: Optional[bool] = Field(True, description="Whether to downsample the image.")
opacity: Optional[float] = Field(1.0, description="The opacity of the image.")
vrange: Optional[tuple[float | int, float | int]] = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
color_bar: Optional[Literal["simple", "full"]] = Field(
"simple", description="The type of the color bar."
)
autorange: Optional[bool] = Field(True, description="Whether to autorange the color bar.")
autorange_mode: Optional[Literal["max", "mean"]] = Field(
"mean", description="Whether to use the mean of the image for autoscaling."
)
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"set",
"set_fft",
"set_log",
"set_rotation",
"set_transpose",
"set_opacity",
"set_autorange",
"set_autorange_mode",
"set_color_map",
"set_auto_downsample",
"set_monitor",
"set_vrange",
"get_data",
]
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image: Optional[BECImageShow] = None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
self.colorbar_bar = None
self._raw_data = None
self._add_color_bar(
self.config.color_bar, self.config.vrange
) # TODO can also support None to not have any colorbar
self.apply_config()
if kwargs:
self.set(**kwargs)
self.connected = False
@property
def raw_data(self) -> np.ndarray:
return self._raw_data
@raw_data.setter
def raw_data(self, data: np.ndarray):
self._raw_data = data
def apply_config(self):
"""
Apply current configuration.
"""
self.set_color_map(self.config.color_map)
self.set_auto_downsample(self.config.downsample)
if self.config.vrange is not None:
self.set_vrange(vrange=self.config.vrange)
def set(self, **kwargs):
"""
Set the properties of the image.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- downsample
- color_map
- monitor
- opacity
- vrange
- fft
- log
- rot
- transpose
- autorange_mode
"""
method_map = {
"downsample": self.set_auto_downsample,
"color_map": self.set_color_map,
"monitor": self.set_monitor,
"opacity": self.set_opacity,
"vrange": self.set_vrange,
"fft": self.set_fft,
"log": self.set_log,
"rot": self.set_rotation,
"transpose": self.set_transpose,
"autorange_mode": self.set_autorange_mode,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
logger.warning(f"Warning: '{key}' is not a recognized property.")
def set_fft(self, enable: bool = False):
"""
Set the FFT of the image.
Args:
enable(bool): Whether to perform FFT on the monitor data.
"""
self.config.processing.fft = enable
def set_log(self, enable: bool = False):
"""
Set the log of the image.
Args:
enable(bool): Whether to perform log on the monitor data.
"""
self.config.processing.log = enable
if enable and self.color_bar and self.config.color_bar == "full":
self.color_bar.autoHistogramRange()
def set_rotation(self, deg_90: int = 0):
"""
Set the rotation of the image.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
"""
self.config.processing.rotation = deg_90
def set_transpose(self, enable: bool = False):
"""
Set the transpose of the image.
Args:
enable(bool): Whether to transpose the image.
"""
self.config.processing.transpose = enable
def set_opacity(self, opacity: float = 1.0):
"""
Set the opacity of the image.
Args:
opacity(float): The opacity of the image.
"""
self.setOpacity(opacity)
self.config.opacity = opacity
def set_autorange(self, autorange: bool = False):
"""
Set the autorange of the color bar.
Args:
autorange(bool): Whether to autorange the color bar.
"""
self.config.autorange = autorange
if self.color_bar and autorange:
self.color_bar.autoHistogramRange()
def set_autorange_mode(self, mode: Literal["max", "mean"] = "mean"):
"""
Set the autorange mode to scale the vrange of the color bar. Choose between min/max or mean +/- std.
Args:
mode(Literal["max","mean"]): Max for min/max or mean for mean +/- std.
"""
self.config.autorange_mode = mode
def set_color_map(self, cmap: str = "magma"):
"""
Set the color map of the image.
Args:
cmap(str): The color map of the image.
"""
self.setColorMap(cmap)
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setColorMap(cmap)
elif self.config.color_bar == "full":
self.color_bar.gradient.loadPreset(cmap)
self.config.color_map = cmap
def set_auto_downsample(self, auto: bool = True):
"""
Set the auto downsample of the image.
Args:
auto(bool): Whether to downsample the image.
"""
self.setAutoDownsample(auto)
self.config.downsample = auto
def set_monitor(self, monitor: str):
"""
Set the monitor of the image.
Args:
monitor(str): The name of the monitor.
"""
self.config.monitor = monitor
def auto_update_vrange(self, stats: ImageStats) -> None:
"""Auto update of the vrange base on the stats of the image.
Args:
stats(ImageStats): The stats of the image.
"""
fumble_factor = 2
if self.config.autorange_mode == "mean":
vmin = max(stats.mean - fumble_factor * stats.std, 0)
vmax = stats.mean + fumble_factor * stats.std
self.set_vrange(vmin, vmax, change_autorange=False)
return
if self.config.autorange_mode == "max":
self.set_vrange(max(stats.minimum, 0), stats.maximum, change_autorange=False)
return
def set_vrange(
self,
vmin: float = None,
vmax: float = None,
vrange: tuple[float, float] = None,
change_autorange: bool = True,
):
"""
Set the range of the color bar.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
"""
if vrange is not None:
vmin, vmax = vrange
self.setLevels([vmin, vmax])
self.config.vrange = (vmin, vmax)
if change_autorange:
self.config.autorange = False
if self.color_bar is not None:
if self.config.color_bar == "simple":
self.color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
# pylint: disable=unexpected-keyword-arg
self.color_bar.setLevels(min=vmin, max=vmax)
self.color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
def get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
def _add_color_bar(
self, color_bar_style: str = "simple", vrange: Optional[tuple[int, int]] = None
):
"""
Add color bar to the layout.
Args:
style(Literal["simple,full"]): The style of the color bar.
vrange(tuple[int,int]): The range of the color bar.
"""
if color_bar_style == "simple":
self.color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(low=vrange[0], high=vrange[1])
self.color_bar.setImageItem(self)
self.parent_image.addItem(self.color_bar, row=1, col=1)
self.config.color_bar = "simple"
elif color_bar_style == "full":
# Setting histogram
self.color_bar = pg.HistogramLUTItem()
self.color_bar.setImageItem(self)
self.color_bar.gradient.loadPreset(self.config.color_map)
if vrange is not None:
self.color_bar.setLevels(min=vrange[0], max=vrange[1])
self.color_bar.setHistogramRange(
vrange[0] - 0.1 * vrange[0], vrange[1] + 0.1 * vrange[1]
)
# Adding histogram to the layout
self.parent_image.addItem(self.color_bar, row=1, col=1)
# save settings
self.config.color_bar = "full"
else:
raise ValueError("style should be 'simple' or 'full'")
def remove(self):
"""Remove the curve from the plot."""
self.parent_image.remove_image(self.config.monitor)
self.rpc_register.remove_rpc(self)

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal
from qtpy.QtCore import QObject, Signal, Slot
@dataclass
@@ -16,51 +17,35 @@ class ImageStats:
mean: float
std: float
@classmethod
def from_data(cls, data: np.ndarray) -> ImageStats:
"""
Get the statistics of the image data.
Args:
data(np.ndarray): The image data.
Returns:
ImageStats: The statistics of the image data.
"""
return cls(maximum=np.max(data), minimum=np.min(data), mean=np.mean(data), std=np.std(data))
# noinspection PyDataclass
class ProcessingConfig(BaseModel):
fft: bool = Field(False, description="Whether to perform FFT on the monitor data.")
log: bool = Field(False, description="Whether to perform log on the monitor data.")
transpose: bool = Field(
fft: Optional[bool] = Field(False, description="Whether to perform FFT on the monitor data.")
log: Optional[bool] = Field(False, description="Whether to perform log on the monitor data.")
center_of_mass: Optional[bool] = Field(
False, description="Whether to calculate the center of mass of the monitor data."
)
transpose: Optional[bool] = Field(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: int = Field(
0, description="The rotation angle of the monitor data before displaying."
rotation: Optional[int] = Field(
None, description="The rotation angle of the monitor data before displaying."
)
model_config: dict = {"validate_assignment": True}
stats: ImageStats = Field(
ImageStats(maximum=0, minimum=0, mean=0, std=0),
description="The statistics of the image data.",
)
model_config: dict = {"validate_assignment": True}
class ImageProcessor(QObject):
class ImageProcessor:
"""
Class for processing the image data.
"""
image_processed = Signal(np.ndarray)
def __init__(self, parent=None, config: ProcessingConfig = None):
super().__init__(parent=parent)
def __init__(self, config: ProcessingConfig = None):
if config is None:
config = ProcessingConfig()
self.config = config
self._current_thread = None
def set_config(self, config: ProcessingConfig):
"""
@@ -124,6 +109,9 @@ class ImageProcessor(QObject):
data_offset = data + offset
return np.log10(data_offset)
# def center_of_mass(self, data: np.ndarray) -> tuple: # TODO check functionality
# return np.unravel_index(np.argmax(data), data.shape)
def update_image_stats(self, data: np.ndarray) -> None:
"""Get the statistics of the image data.
@@ -137,7 +125,15 @@ class ImageProcessor(QObject):
self.config.stats.std = np.std(data)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""Core processing logic without threading overhead."""
"""
Process the data according to the configuration.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
@@ -148,3 +144,40 @@ class ImageProcessor(QObject):
data = self.log(data)
self.update_image_stats(data)
return data
class ProcessorWorker(QObject):
"""
Worker for processing the image data.
"""
processed = Signal(str, np.ndarray)
stats = Signal(str, ImageStats)
stopRequested = Signal()
finished = Signal()
def __init__(self, processor):
super().__init__()
self.processor = processor
self._isRunning = False
self.stopRequested.connect(self.stop)
@Slot(str, np.ndarray)
def process_image(self, device: str, image: np.ndarray):
"""
Process the image data.
Args:
device(str): The name of the device.
image(np.ndarray): The image data.
"""
self._isRunning = True
processed_image = self.processor.process_image(image)
self._isRunning = False
if not self._isRunning:
self.processed.emit(device, processed_image)
self.stats.emit(self.processor.config.stats)
self.finished.emit()
def stop(self):
self._isRunning = False

View File

@@ -0,0 +1,525 @@
from __future__ import annotations
from collections import defaultdict
from typing import Optional, Union
import numpy as np
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
from bec_widgets.utils import Colors, EntryValidator
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform import Signal, SignalData
logger = bec_logger.logger
class MotorMapConfig(SubplotConfig):
signals: Optional[Signal] = Field(None, description="Signals of the motor map")
color: Optional[str | tuple] = Field(
(255, 255, 255, 255), description="The color of the last point of current position."
)
scatter_size: Optional[int] = Field(5, description="Size of the scatter points.")
max_points: Optional[int] = Field(5000, description="Maximum number of points to display.")
num_dim_points: Optional[int] = Field(
100,
description="Number of points to dim before the color remains same for older recorded position.",
)
precision: Optional[int] = Field(2, description="Decimal precision of the motor position.")
background_value: Optional[int] = Field(
25, description="Background value of the motor map. Has to be between 0 and 255."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
@field_validator("background_value")
def validate_background_value(cls, value):
if not 0 <= value <= 255:
raise PydanticCustomError(
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
)
return value
class BECMotorMap(BECPlotBase):
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"change_motors",
"set_max_points",
"set_precision",
"set_num_dim_points",
"set_background_value",
"set_scatter_size",
"get_data",
"export",
"remove",
"reset_history",
]
# QT Signals
update_signal = pyqtSignal()
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[MotorMapConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
self.entry_validator = EntryValidator(self.dev)
# connect update signal to update plot
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self.apply_config(self.config)
def apply_config(self, config: dict | MotorMapConfig):
"""
Apply the config to the motor map.
Args:
config(dict|MotorMapConfig): Config to be applied.
"""
if isinstance(config, dict):
try:
config = MotorMapConfig(**config)
except ValidationError as e:
logger.error(f"Error in applying config: {e}")
return
self.config = config
self.plot_item.clear()
self.motor_x = None
self.motor_y = None
self.database_buffer = {"x": [], "y": []}
self.plot_components = defaultdict(dict) # container for plot components
self.apply_axis_config()
if self.config.signals is not None:
self.change_motors(
motor_x=self.config.signals.x.name,
motor_y=self.config.signals.y.name,
motor_x_entry=self.config.signals.x.entry,
motor_y_entry=self.config.signals.y.entry,
)
@Slot(str, str, str, str, bool)
def change_motors(
self,
motor_x: str,
motor_y: str,
motor_x_entry: str = None,
motor_y_entry: str = None,
validate_bec: bool = True,
) -> None:
"""
Change the active motors for the plot.
Args:
motor_x(str): Motor name for the X axis.
motor_y(str): Motor name for the Y axis.
motor_x_entry(str): Motor entry for the X axis.
motor_y_entry(str): Motor entry for the Y axis.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
self.plot_item.clear()
motor_x_entry, motor_y_entry = self._validate_signal_entries(
motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec
)
motor_x_limit = self._get_motor_limit(motor_x)
motor_y_limit = self._get_motor_limit(motor_y)
signal = Signal(
source="device_readback",
x=SignalData(name=motor_x, entry=motor_x_entry, limits=motor_x_limit),
y=SignalData(name=motor_y, entry=motor_y_entry, limits=motor_y_limit),
)
self.config.signals = signal
# reconnect the signals
self._connect_motor_to_slots()
self.database_buffer = {"x": [], "y": []}
# Redraw the motor map
self._make_motor_map()
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
data = {"x": self.database_buffer["x"], "y": self.database_buffer["y"]}
return data
def reset_history(self):
"""
Reset the history of the motor map.
"""
self.database_buffer["x"] = [self.database_buffer["x"][-1]]
self.database_buffer["y"] = [self.database_buffer["y"][-1]]
self.update_signal.emit()
def set_color(self, color: str | tuple):
"""
Set color of the motor trace.
Args:
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
"""
if isinstance(color, str):
color = Colors.validate_color(color)
color = Colors.hex_to_rgba(color, 255)
self.config.color = color
self.update_signal.emit()
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
Args:
max_points(int): Maximum number of points to display.
"""
self.config.max_points = max_points
self.update_signal.emit()
def set_precision(self, precision: int) -> None:
"""
Set the decimal precision of the motor position.
Args:
precision(int): Decimal precision of the motor position.
"""
self.config.precision = precision
self.update_signal.emit()
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of dim points for the motor map.
Args:
num_dim_points(int): Number of dim points.
"""
self.config.num_dim_points = num_dim_points
self.update_signal.emit()
def set_background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
self.config.background_value = background_value
self._swap_limit_map()
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map plot.
Args:
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
self.update_signal.emit()
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.motor_x is not None and self.motor_y is not None:
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints)
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
self._disconnect_current_motors()
self.motor_x = self.config.signals.x.name
self.motor_y = self.config.signals.y.name
endpoints = [
MessageEndpoints.device_readback(self.motor_x),
MessageEndpoints.device_readback(self.motor_y),
]
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints)
def _swap_limit_map(self):
"""Swap the limit map."""
self.plot_item.removeItem(self.plot_components["limit_map"])
if self.config.signals.x.limits is not None and self.config.signals.y.limits is not None:
self.plot_components["limit_map"] = self._make_limit_map(
self.config.signals.x.limits, self.config.signals.y.limits
)
self.plot_components["limit_map"].setZValue(-1)
self.plot_item.addItem(self.plot_components["limit_map"])
def _make_motor_map(self):
"""
Create the motor map plot.
"""
# Create limit map
motor_x_limit = self.config.signals.x.limits
motor_y_limit = self.config.signals.y.limits
if motor_x_limit is not None or motor_y_limit is not None:
self.plot_components["limit_map"] = self._make_limit_map(motor_x_limit, motor_y_limit)
self.plot_item.addItem(self.plot_components["limit_map"])
self.plot_components["limit_map"].setZValue(-1)
# Create scatter plot
scatter_size = self.config.scatter_size
self.plot_components["scatter"] = pg.ScatterPlotItem(
size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255)
)
self.plot_item.addItem(self.plot_components["scatter"])
self.plot_components["scatter"].setZValue(0)
# Enable Grid
self.set_grid(True, True)
# Add the crosshair for initial motor coordinates
initial_position_x = self._get_motor_init_position(
self.motor_x, self.config.signals.x.entry, self.config.precision
)
initial_position_y = self._get_motor_init_position(
self.motor_y, self.config.signals.y.entry, self.config.precision
)
self.database_buffer["x"] = [initial_position_x]
self.database_buffer["y"] = [initial_position_y]
self.plot_components["scatter"].setData([initial_position_x], [initial_position_y])
self._add_coordinantes_crosshair(initial_position_x, initial_position_y)
# Set default labels for the plot
self.set(x_label=f"Motor X ({self.motor_x})", y_label=f"Motor Y ({self.motor_y})")
self.update_signal.emit()
def _add_coordinantes_crosshair(self, x: float, y: float) -> None:
"""
Add crosshair to the plot to highlight the current position.
Args:
x(float): X coordinate.
y(float): Y coordinate.
"""
# Crosshair to highlight the current position
highlight_H = pg.InfiniteLine(
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
highlight_V = pg.InfiniteLine(
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
# Add crosshair to the curve list for future referencing
self.plot_components["highlight_H"] = highlight_H
self.plot_components["highlight_V"] = highlight_V
# Add crosshair to the plot
self.plot_item.addItem(highlight_H)
self.plot_item.addItem(highlight_V)
highlight_V.setPos(x)
highlight_H.setPos(y)
def _make_limit_map(self, limits_x: list, limits_y: list) -> pg.ImageItem:
"""
Create a limit map for the motor map plot.
Args:
limits_x(list): Motor limits for the x axis.
limits_y(list): Motor limits for the y axis.
Returns:
pg.ImageItem: Limit map.
"""
limit_x_min, limit_x_max = limits_x
limit_y_min, limit_y_max = limits_y
map_width = int(limit_x_max - limit_x_min + 1)
map_height = int(limit_y_max - limit_y_min + 1)
# Create limits map
background_value = self.config.background_value
limit_map_data = np.full((map_width, map_height), background_value, dtype=np.float32)
limit_map = pg.ImageItem()
limit_map.setImage(limit_map_data)
# Translate and scale the image item to match the motor coordinates
tr = QtGui.QTransform()
tr.translate(limit_x_min, limit_y_min)
limit_map.setTransform(tr)
return limit_map
def _get_motor_init_position(self, name: str, entry: str, precision: int) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
entry(str): Motor entry.
precision(int): Decimal precision of the motor position.
Returns:
float: Motor initial position.
"""
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
return init_position
def _validate_signal_entries(
self,
x_name: str,
y_name: str,
x_entry: str | None,
y_entry: str | None,
validate_bec: bool = True,
) -> tuple[str, str]:
"""
Validate the signal name and entry.
Args:
x_name(str): Name of the x signal.
y_name(str): Name of the y signal.
x_entry(str|None): Entry of the x signal.
y_entry(str|None): Entry of the y signal.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
Returns:
tuple[str,str]: Validated x and y entries.
"""
if validate_bec:
x_entry = self.entry_validator.validate_signal(x_name, x_entry)
y_entry = self.entry_validator.validate_signal(y_name, y_entry)
else:
x_entry = x_name if x_entry is None else x_entry
y_entry = y_name if y_entry is None else y_entry
return x_entry, y_entry
def _get_motor_limit(self, motor: str) -> Union[list | None]: # TODO check if works correctly
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
Returns:
float: Motor limit.
"""
try:
limits = self.dev[motor].limits
if limits == [0, 0]:
return None
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
logger.error(f"The device '{motor}' does not have defined limits.")
return None
@Slot()
def _update_plot(self, _=None):
"""Update the motor map plot."""
# If the number of points exceeds max_points, delete the oldest points
if len(self.database_buffer["x"]) > self.config.max_points:
self.database_buffer["x"] = self.database_buffer["x"][-self.config.max_points :]
self.database_buffer["y"] = self.database_buffer["y"][-self.config.max_points :]
x = self.database_buffer["x"]
y = self.database_buffer["y"]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
# RGB color
r, g, b, a = self.config.color
# Calculate the decrement step based on self.num_dim_points
num_dim_points = self.config.num_dim_points
decrement_step = (255 - 50) / num_dim_points
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
dim_r = int(r * (brightness / 255))
dim_g = int(g * (brightness / 255))
dim_b = int(b * (brightness / 255))
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
scatter_size = self.config.scatter_size
# Update the scatter plot
self.plot_components["scatter"].setData(
x=x, y=y, brush=brushes, pen=None, size=scatter_size
)
# Get last know position for crosshair
current_x = x[-1]
current_y = y[-1]
# Update the crosshair
self.plot_components["highlight_V"].setPos(current_x)
self.plot_components["highlight_H"].setPos(current_y)
# TODO not update title but some label
# Update plot title
precision = self.config.precision
self.set_title(
f"Motor position: ({round(float(current_x),precision)}, {round(float(current_y),precision)})"
)
@Slot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
"""
Update the motor map plot with the new motor position.
Args:
msg(dict): Message from the device readback.
metadata(dict): Metadata of the message.
"""
if self.motor_x is None or self.motor_y is None:
return
if self.motor_x in msg["signals"]:
x = msg["signals"][self.motor_x]["value"]
self.database_buffer["x"].append(x)
self.database_buffer["y"].append(self.database_buffer["y"][-1])
elif self.motor_y in msg["signals"]:
y = msg["signals"][self.motor_y]["value"]
self.database_buffer["y"].append(y)
self.database_buffer["x"].append(self.database_buffer["x"][-1])
self.update_signal.emit()
def cleanup(self):
"""Cleanup the widget."""
self._disconnect_current_motors()

View File

@@ -0,0 +1,340 @@
from collections import deque
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from pydantic import Field, field_validator
from pyqtgraph.exporters import MatplotlibExporter
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import Colors
from bec_widgets.widgets.containers.figure.plots.plot_base import BECPlotBase, SubplotConfig
logger = bec_logger.logger
class BECMultiWaveformConfig(SubplotConfig):
color_palette: Optional[str] = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
curve_limit: Optional[int] = Field(
200, description="The maximum number of curves to display on the plot."
)
flush_buffer: Optional[bool] = Field(
False, description="Flush the buffer of the plot widget when the curve limit is reached."
)
monitor: Optional[str] = Field(
None, description="The monitor to set for the plot widget."
) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
highlight_last_curve: Optional[bool] = Field(
True, description="Highlight the last curve on the plot."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
class BECMultiWaveform(BECPlotBase):
monitor_signal_updated = Signal()
highlighted_curve_index_changed = Signal(int)
USER_ACCESS = [
"_rpc_id",
"_config_dict",
"curves",
"set_monitor",
"set_opacity",
"set_curve_limit",
"set_curve_highlight",
"set_colormap",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_colormap",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"get_all_data",
"remove",
]
def __init__(
self,
parent: Optional[QWidget] = None,
parent_figure=None,
config: Optional[BECMultiWaveformConfig] = None,
client=None,
gui_id: Optional[str] = None,
):
if config is None:
config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
)
self.old_scan_id = None
self.scan_id = None
self.monitor = None
self.connected = False
self.current_highlight_index = 0
self._curves = deque()
self.visible_curves = []
self.number_of_visible_curves = 0
# Get bec shortcuts dev, scans, queue, scan_storage, dap
self.get_bec_shortcuts()
@property
def curves(self) -> deque:
"""
Get the curves of the plot widget as a deque.
Returns:
deque: Deque of curves.
"""
return self._curves
@curves.setter
def curves(self, value: deque):
self._curves = value
@property
def highlight_last_curve(self) -> bool:
"""
Get the highlight_last_curve property.
Returns:
bool: The highlight_last_curve property.
"""
return self.config.highlight_last_curve
@highlight_last_curve.setter
def highlight_last_curve(self, value: bool):
self.config.highlight_last_curve = value
def set_monitor(self, monitor: str):
"""
Set the monitor for the plot widget.
Args:
monitor (str): The monitor to set.
"""
self.config.monitor = monitor
self._connect_monitor()
def _connect_monitor(self):
"""
Connect the monitor to the plot widget.
"""
try:
previous_monitor = self.monitor
except AttributeError:
previous_monitor = None
if previous_monitor and self.connected is True:
self.bec_dispatcher.disconnect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
)
if self.config.monitor and self.connected is False:
self.bec_dispatcher.connect_slot(
self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
)
self.connected = True
self.monitor = self.config.monitor
@Slot(dict, dict)
def on_monitor_1d_update(self, msg: dict, metadata: dict):
"""
Update the plot widget with the monitor data.
Args:
msg(dict): The message data.
metadata(dict): The metadata of the message.
"""
data = msg.get("data", None)
current_scan_id = metadata.get("scan_id", None)
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self.clear_curves()
self.curves.clear()
if self.crosshair:
self.crosshair.clear_markers()
# Always create a new curve and add it
curve = pg.PlotDataItem()
curve.setData(data)
self.plot_item.addItem(curve)
self.curves.append(curve)
# Max Trace and scale colors
self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
self.monitor_signal_updated.emit()
@Slot(int)
def set_curve_highlight(self, index: int):
"""
Set the curve highlight based on visible curves.
Args:
index (int): The index of the curve to highlight among visible curves.
"""
self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
num_visible_curves = len(self.plot_item.visible_curves)
self.number_of_visible_curves = num_visible_curves
if num_visible_curves == 0:
return # No curves to highlight
if index >= num_visible_curves:
index = num_visible_curves - 1
elif index < 0:
index = num_visible_curves + index
self.current_highlight_index = index
num_colors = num_visible_curves
colors = Colors.evenly_spaced_colors(
colormap=self.config.color_palette, num=num_colors, format="HEX"
)
for i, curve in enumerate(self.plot_item.visible_curves):
curve.setPen()
if i == self.current_highlight_index:
curve.setPen(pg.mkPen(color=colors[i], width=5))
curve.setAlpha(alpha=1, auto=False)
curve.setZValue(1)
else:
curve.setPen(pg.mkPen(color=colors[i], width=1))
curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
curve.setZValue(0)
self.highlighted_curve_index_changed.emit(self.current_highlight_index)
@Slot(int)
def set_opacity(self, opacity: int):
"""
Set the opacity of the curve on the plot.
Args:
opacity(int): The opacity of the curve. 0-100.
"""
self.config.opacity = max(0, min(100, opacity))
self.set_curve_highlight(self.current_highlight_index)
@Slot(int, bool)
def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
"""
Set the maximum number of traces to display on the plot.
Args:
max_trace (int): The maximum number of traces to display.
flush_buffer (bool): Flush the buffer.
"""
self.config.curve_limit = max_trace
self.config.flush_buffer = flush_buffer
if self.config.curve_limit is None:
self.scale_colors()
return
if self.config.flush_buffer:
# Remove excess curves from the plot and the deque
while len(self.curves) > self.config.curve_limit:
curve = self.curves.popleft()
self.plot_item.removeItem(curve)
else:
# Hide or show curves based on the new max_trace
num_curves_to_show = min(self.config.curve_limit, len(self.curves))
for i, curve in enumerate(self.curves):
if i < len(self.curves) - num_curves_to_show:
curve.hide()
else:
curve.show()
self.scale_colors()
def scale_colors(self):
"""
Scale the colors of the curves based on the current colormap.
"""
if self.config.highlight_last_curve:
self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
else:
self.set_curve_highlight(self.current_highlight_index)
def set_colormap(self, colormap: str):
"""
Set the colormap for the curves.
Args:
colormap(str): Colormap for the curves.
"""
self.config.color_palette = colormap
self.set_curve_highlight(self.current_highlight_index)
def hook_crosshair(self) -> None:
super().hook_crosshair()
if self.crosshair:
self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
if self.curves:
self.crosshair.update_highlighted_curve(self.current_highlight_index)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
"""
Extract all curve data into a dictionary or a pandas DataFrame.
Args:
output (Literal["dict", "pandas"]): Format of the output data.
Returns:
dict | pd.DataFrame: Data of all curves in the specified format.
"""
data = {}
try:
import pandas as pd
except ImportError:
pd = None
if output == "pandas":
logger.warning(
"Pandas is not installed. "
"Please install pandas using 'pip install pandas'."
"Output will be dictionary instead."
)
output = "dict"
curve_keys = []
curves_list = list(self.curves)
for i, curve in enumerate(curves_list):
x_data, y_data = curve.getData()
if x_data is not None or y_data is not None:
key = f"curve_{i}"
curve_keys.append(key)
if output == "dict":
data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
elif output == "pandas" and pd is not None:
data[key] = pd.DataFrame({"x": x_data, "y": y_data})
if output == "pandas" and pd is not None:
combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
return combined_data
return data
def clear_curves(self):
"""
Remove all curves from the plot, excluding crosshair items.
"""
items_to_remove = []
for item in self.plot_item.items:
if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
items_to_remove.append(item)
for item in items_to_remove:
self.plot_item.removeItem(item)
def export_to_matplotlib(self):
"""
Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
"""
MatplotlibExporter(self.plot_item).export()

View File

@@ -0,0 +1,505 @@
from __future__ import annotations
from typing import Literal, Optional
import bec_qthemes
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils import BECConnector, ConnectionConfig
from bec_widgets.utils.crosshair import Crosshair
from bec_widgets.utils.fps_counter import FPSCounter
from bec_widgets.utils.plot_indicator_items import BECArrowItem, BECTickItem
logger = bec_logger.logger
class AxisConfig(BaseModel):
title: Optional[str] = Field(None, description="The title of the axes.")
title_size: Optional[int] = Field(None, description="The font size of the title.")
x_label: Optional[str] = Field(None, description="The label for the x-axis.")
x_label_size: Optional[int] = Field(None, description="The font size of the x-axis label.")
y_label: Optional[str] = Field(None, description="The label for the y-axis.")
y_label_size: Optional[int] = Field(None, description="The font size of the y-axis label.")
legend_label_size: Optional[int] = Field(
None, description="The font size of the legend labels."
)
x_scale: Literal["linear", "log"] = Field("linear", description="The scale of the x-axis.")
y_scale: Literal["linear", "log"] = Field("linear", description="The scale of the y-axis.")
x_lim: Optional[tuple] = Field(None, description="The limits of the x-axis.")
y_lim: Optional[tuple] = Field(None, description="The limits of the y-axis.")
x_grid: bool = Field(False, description="Show grid on the x-axis.")
y_grid: bool = Field(False, description="Show grid on the y-axis.")
outer_axes: bool = Field(False, description="Show the outer axes of the plot widget.")
model_config: dict = {"validate_assignment": True}
class SubplotConfig(ConnectionConfig):
parent_id: Optional[str] = Field(None, description="The parent figure of the plot.")
# Coordinates in the figure
row: int = Field(0, description="The row coordinate in the figure.")
col: int = Field(0, description="The column coordinate in the figure.")
# Appearance settings
axis: AxisConfig = Field(
default_factory=AxisConfig, description="The axis configuration of the plot."
)
class BECViewBox(pg.ViewBox):
sigPaint = Signal()
def paint(self, painter, opt, widget):
super().paint(painter, opt, widget)
self.sigPaint.emit()
def itemBoundsChanged(self, item):
self._itemBoundsCache.pop(item, None)
if (self.state["autoRange"][0] is not False) or (self.state["autoRange"][1] is not False):
# check if the call is coming from a mouse-move event
if hasattr(item, "skip_auto_range") and item.skip_auto_range:
return
self._autoRangeNeedsUpdate = True
self.update()
class BECPlotBase(BECConnector, pg.GraphicsLayout):
crosshair_position_changed = Signal(tuple)
crosshair_position_clicked = Signal(tuple)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_clicked = Signal(tuple)
USER_ACCESS = [
"_config_dict",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_grid",
"set_outer_axes",
"enable_fps_monitor",
"lock_aspect_ratio",
"export",
"remove",
"set_legend_label_size",
]
def __init__(
self,
parent: Optional[QWidget] = None, # TODO decide if needed for this class
parent_figure=None,
config: Optional[SubplotConfig] = None,
client=None,
gui_id: Optional[str] = None,
**kwargs,
):
if config is None:
config = SubplotConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
pg.GraphicsLayout.__init__(self, parent)
self.figure = parent_figure
self.plot_item = pg.PlotItem(viewBox=BECViewBox(parent=self, enableMenu=True), parent=self)
self.addItem(self.plot_item, row=1, col=0)
self.add_legend()
self.crosshair = None
self.fps_monitor = None
self.fps_label = None
self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item)
self.arrow_item = BECArrowItem(parent=self, plot_item=self.plot_item)
self._connect_to_theme_change()
def _connect_to_theme_change(self):
"""Connect to the theme change signal."""
qapp = QApplication.instance()
if hasattr(qapp, "theme_signal"):
qapp.theme_signal.theme_updated.connect(self._update_theme)
@Slot(str)
def _update_theme(self, theme: str):
"""Update the theme."""
if theme is None:
qapp = QApplication.instance()
if hasattr(qapp, "theme"):
theme = qapp.theme.theme
else:
theme = "dark"
self.apply_theme(theme)
def apply_theme(self, theme: str):
"""
Apply the theme to the plot widget.
Args:
theme(str, optional): The theme to be applied.
"""
palette = bec_qthemes.load_palette(theme)
text_pen = pg.mkPen(color=palette.text().color())
for axis in ["left", "bottom", "right", "top"]:
self.plot_item.getAxis(axis).setPen(text_pen)
self.plot_item.getAxis(axis).setTextPen(text_pen)
if self.plot_item.legend is not None:
for sample, label in self.plot_item.legend.items:
label.setText(label.text, color=palette.text().color())
def set(self, **kwargs) -> None:
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
# Mapping of keywords to setter methods
method_map = {
"title": self.set_title,
"x_label": self.set_x_label,
"y_label": self.set_y_label,
"x_scale": self.set_x_scale,
"y_scale": self.set_y_scale,
"x_lim": self.set_x_lim,
"y_lim": self.set_y_lim,
"legend_label_size": self.set_legend_label_size,
}
for key, value in kwargs.items():
if key in method_map:
method_map[key](value)
else:
logger.warning(f"Warning: '{key}' is not a recognized property.")
def apply_axis_config(self):
"""Apply the axis configuration to the plot widget."""
config_mappings = {
"title": self.config.axis.title,
"x_label": self.config.axis.x_label,
"y_label": self.config.axis.y_label,
"x_scale": self.config.axis.x_scale,
"y_scale": self.config.axis.y_scale,
"x_lim": self.config.axis.x_lim,
"y_lim": self.config.axis.y_lim,
}
self.set(**{k: v for k, v in config_mappings.items() if v is not None})
def set_legend_label_size(self, size: int = None):
"""
Set the font size of the legend.
Args:
size(int): Font size of the legend.
"""
if not self.plot_item.legend:
return
if self.config.axis.legend_label_size or size:
if size:
self.config.axis.legend_label_size = size
scale = (
size / 9
) # 9 is the default font size of the legend, so we always scale it against 9
self.plot_item.legend.setScale(scale)
def get_text_color(self):
return "#FFF" if self.figure.config.theme == "dark" else "#000"
def set_title(self, title: str, size: int = None):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot widget.
size(int): Font size of the title.
"""
if self.config.axis.title_size or size:
if size:
self.config.axis.title_size = size
style = {"color": self.get_text_color(), "size": f"{self.config.axis.title_size}pt"}
else:
style = {}
self.plot_item.setTitle(title, **style)
self.config.axis.title = title
def set_x_label(self, label: str, size: int = None):
"""
Set the label of the x-axis.
Args:
label(str): Label of the x-axis.
size(int): Font size of the label.
"""
if self.config.axis.x_label_size or size:
if size:
self.config.axis.x_label_size = size
style = {
"color": self.get_text_color(),
"font-size": f"{self.config.axis.x_label_size}pt",
}
else:
style = {}
self.plot_item.setLabel("bottom", label, **style)
self.config.axis.x_label = label
def set_y_label(self, label: str, size: int = None):
"""
Set the label of the y-axis.
Args:
label(str): Label of the y-axis.
size(int): Font size of the label.
"""
if self.config.axis.y_label_size or size:
if size:
self.config.axis.y_label_size = size
color = self.get_text_color()
style = {"color": color, "font-size": f"{self.config.axis.y_label_size}pt"}
else:
style = {}
self.plot_item.setLabel("left", label, **style)
self.config.axis.y_label = label
def set_x_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the x-axis.
Args:
scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self.plot_item.setLogMode(x=(scale == "log"))
self.config.axis.x_scale = scale
def set_y_scale(self, scale: Literal["linear", "log"] = "linear"):
"""
Set the scale of the y-axis.
Args:
scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self.plot_item.setLogMode(y=(scale == "log"))
self.config.axis.y_scale = scale
def set_x_lim(self, *args) -> None:
"""
Set the limits of the x-axis. This method can accept either two separate arguments
for the minimum and maximum x-axis values, or a single tuple containing both limits.
Usage:
set_x_lim(x_min, x_max)
set_x_lim((x_min, x_max))
Args:
*args: A variable number of arguments. Can be two integers (x_min and x_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
x_min, x_max = args[0]
elif len(args) == 2:
x_min, x_max = args
else:
raise ValueError("set_x_lim expects either two separate arguments or a single tuple")
self.plot_item.setXRange(x_min, x_max)
self.config.axis.x_lim = (x_min, x_max)
def set_y_lim(self, *args) -> None:
"""
Set the limits of the y-axis. This method can accept either two separate arguments
for the minimum and maximum y-axis values, or a single tuple containing both limits.
Usage:
set_y_lim(y_min, y_max)
set_y_lim((y_min, y_max))
Args:
*args: A variable number of arguments. Can be two integers (y_min and y_max)
or a single tuple with two integers.
"""
if len(args) == 1 and isinstance(args[0], tuple):
y_min, y_max = args[0]
elif len(args) == 2:
y_min, y_max = args
else:
raise ValueError("set_y_lim expects either two separate arguments or a single tuple")
self.plot_item.setYRange(y_min, y_max)
self.config.axis.y_lim = (y_min, y_max)
def set_grid(self, x: bool = False, y: bool = False):
"""
Set the grid of the plot widget.
Args:
x(bool): Show grid on the x-axis.
y(bool): Show grid on the y-axis.
"""
self.plot_item.showGrid(x, y)
self.config.axis.x_grid = x
self.config.axis.y_grid = y
def set_outer_axes(self, show: bool = True):
"""
Set the outer axes of the plot widget.
Args:
show(bool): Show the outer axes.
"""
self.plot_item.showAxis("top", show)
self.plot_item.showAxis("right", show)
self.config.axis.outer_axes = show
def add_legend(self):
"""Add legend to the plot"""
self.plot_item.addLegend()
def lock_aspect_ratio(self, lock):
"""
Lock aspect ratio.
Args:
lock(bool): True to lock, False to unlock.
"""
self.plot_item.setAspectLocked(lock)
def set_auto_range(self, enabled: bool, axis: str = "xy"):
"""
Set the auto range of the plot widget.
Args:
enabled(bool): If True, enable the auto range.
axis(str, optional): The axis to enable the auto range.
- "xy": Enable auto range for both x and y axis.
- "x": Enable auto range for x axis.
- "y": Enable auto range for y axis.
"""
self.plot_item.enableAutoRange(axis, enabled)
############################################################
###################### Crosshair ###########################
############################################################
def hook_crosshair(self) -> None:
"""Hook the crosshair to all plots."""
if self.crosshair is None:
self.crosshair = Crosshair(self.plot_item, precision=3)
self.crosshair.crosshairChanged.connect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked)
def unhook_crosshair(self) -> None:
"""Unhook the crosshair from all plots."""
if self.crosshair is not None:
self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed)
self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked)
self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed)
self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked)
self.crosshair.cleanup()
self.crosshair.deleteLater()
self.crosshair = None
def toggle_crosshair(self) -> None:
"""Toggle the crosshair on all plots."""
if self.crosshair is None:
return self.hook_crosshair()
self.unhook_crosshair()
@Slot()
def reset(self) -> None:
"""Reset the plot widget."""
if self.crosshair is not None:
self.crosshair.clear_markers()
self.crosshair.update_markers()
############################################################
##################### FPS Counter ##########################
############################################################
def update_fps_label(self, fps: float) -> None:
"""
Update the FPS label.
Args:
fps(float): The frames per second.
"""
if self.fps_label:
self.fps_label.setText(f"FPS: {fps:.2f}")
def hook_fps_monitor(self):
"""Hook the FPS monitor to the plot."""
if self.fps_monitor is None:
# text_color = self.get_text_color()#TODO later
self.fps_monitor = FPSCounter(self.plot_item.vb) # text_color=text_color)
self.fps_label = pg.LabelItem(justify="right")
self.addItem(self.fps_label, row=0, col=0)
self.fps_monitor.sigFpsUpdate.connect(self.update_fps_label)
def unhook_fps_monitor(self, delete_label=True):
"""Unhook the FPS monitor from the plot."""
if self.fps_monitor is not None:
# Remove Monitor
self.fps_monitor.cleanup()
self.fps_monitor.deleteLater()
self.fps_monitor = None
if self.fps_label is not None and delete_label:
# Remove Label
self.removeItem(self.fps_label)
self.fps_label.deleteLater()
self.fps_label = None
def enable_fps_monitor(self, enable: bool = True):
"""
Enable the FPS monitor.
Args:
enable(bool): True to enable, False to disable.
"""
if enable and self.fps_monitor is None:
self.hook_fps_monitor()
elif not enable and self.fps_monitor is not None:
self.unhook_fps_monitor()
def export(self):
"""Show the Export Dialog of the plot widget."""
scene = self.plot_item.scene()
scene.contextMenuItem = self.plot_item
scene.showExportDialog()
def remove(self):
"""Remove the plot widget from the figure."""
if self.figure is not None:
self.figure.remove(widget_id=self.gui_id)
def cleanup_pyqtgraph(self):
"""Cleanup pyqtgraph items."""
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=False)
self.tick_item.cleanup()
self.arrow_item.cleanup()
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()
item.ctrlMenu.deleteLater()

File diff suppressed because it is too large Load Diff

View File

@@ -1,64 +1,73 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
from typing import TYPE_CHECKING, Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.logger import bec_logger
from pydantic import BaseModel, Field, field_validator
from qtpy import QtCore
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform
if TYPE_CHECKING:
from bec_widgets.widgets.containers.figure.plots.waveform import BECWaveform1D
logger = bec_logger.logger
# noinspection PyDataclass
class DeviceSignal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
class SignalData(BaseModel):
"""The data configuration of a signal in the 1D waveform widget for x and y axis."""
name: str
entry: str
dap: str | None = None
dap_oversample: int = 1
unit: Optional[str] = None # todo implement later
modifier: Optional[str] = None # todo implement later
limits: Optional[list[float]] = None # todo implement later
model_config: dict = {"validate_assignment": True}
class Signal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
source: str
x: Optional[SignalData] = None
y: SignalData
z: Optional[SignalData] = None
dap: Optional[str] = None
model_config: dict = {"validate_assignment": True}
# noinspection PyDataclass
class CurveConfig(ConnectionConfig):
parent_id: str | None = Field(None, description="The parent plot of the curve.")
label: str | None = Field(None, description="The label of the curve.")
color: str | tuple | None = Field(None, description="The color of the curve.")
symbol: str | None = Field("o", description="The symbol of the curve.")
symbol_color: str | tuple | None = Field(
parent_id: Optional[str] = Field(None, description="The parent plot of the curve.")
label: Optional[str] = Field(None, description="The label of the curve.")
color: Optional[str | tuple] = Field(None, description="The color of the curve.")
symbol: Optional[str | None] = Field("o", description="The symbol of the curve.")
symbol_color: Optional[str | tuple] = Field(
None, description="The color of the symbol of the curve."
)
symbol_size: int | None = Field(7, description="The size of the symbol of the curve.")
pen_width: int | None = Field(4, description="The width of the pen of the curve.")
pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field(
symbol_size: Optional[int] = Field(7, description="The size of the symbol of the curve.")
pen_width: Optional[int] = Field(4, description="The width of the pen of the curve.")
pen_style: Optional[Literal["solid", "dash", "dot", "dashdot"]] = Field(
"solid", description="The style of the pen of the curve."
)
source: Literal["device", "dap", "custom"] = Field(
"custom", description="The source of the curve."
)
signal: DeviceSignal | None = Field(None, description="The signal of the curve.")
parent_label: str | None = Field(
None, description="The label of the parent plot, only relevant for dap curves."
source: Optional[str] = Field(None, description="The source of the curve.")
signals: Optional[Signal] = Field(None, description="The signal of the curve.")
color_map_z: Optional[str] = Field(
"magma", description="The colormap of the curves z gradient.", validate_default=True
)
model_config: dict = {"validate_assignment": True}
_validate_color_map_z = field_validator("color_map_z")(Colors.validate_color_map)
_validate_color = field_validator("color")(Colors.validate_color)
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
class Curve(BECConnector, pg.PlotDataItem):
class BECCurve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"dap_params",
"_rpc_id",
"_config_dict",
"set",
@@ -72,17 +81,14 @@ class Curve(BECConnector, pg.PlotDataItem):
"set_pen_style",
"get_data",
"dap_params",
"dap_summary",
"dap_oversample",
"dap_oversample.setter",
]
def __init__(
self,
name: str | None = None,
config: CurveConfig | None = None,
gui_id: str | None = None,
parent_item: Waveform | None = None,
name: Optional[str] = None,
config: Optional[CurveConfig] = None,
gui_id: Optional[str] = None,
parent_item: Optional[BECWaveform1D] = None,
**kwargs,
):
if config is None:
@@ -90,32 +96,18 @@ class Curve(BECConnector, pg.PlotDataItem):
self.config = config
else:
self.config = config
self.parent_item = parent_item
self.parent_id = self.parent_item.gui_id
super().__init__(name=name, config=config, gui_id=gui_id, **kwargs)
# config.widget_class = self.__class__.__name__
super().__init__(config=config, gui_id=gui_id, **kwargs)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.apply_config()
self.dap_params = None
self.dap_summary = None
if kwargs:
self.set(**kwargs)
def parent(self):
return self.parent_item
def apply_config(self, config: dict | CurveConfig | None = None, **kwargs) -> None:
"""
Apply the configuration to the curve.
Args:
config(dict|CurveConfig, optional): The configuration to apply.
"""
if config is not None:
if isinstance(config, dict):
config = CurveConfig(**config)
self.config = config
def apply_config(self):
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
@@ -137,64 +129,21 @@ class Curve(BECConnector, pg.PlotDataItem):
@property
def dap_params(self):
"""
Get the dap parameters.
"""
return self._dap_params
@dap_params.setter
def dap_params(self, value):
"""
Set the dap parameters.
Args:
value(dict): The dap parameters.
"""
self._dap_params = value
@property
def dap_summary(self):
"""
Get the dap summary.
"""
return self._dap_report
@dap_summary.setter
def dap_summary(self, value):
"""
Set the dap summary.
"""
self._dap_report = value
@property
def dap_oversample(self):
"""
Get the dap oversample.
"""
return self.config.signal.dap_oversample
@dap_oversample.setter
def dap_oversample(self, value):
"""
Set the dap oversample.
Args:
value(int): The dap oversample.
"""
self.config.signal.dap_oversample = value
self.parent_item.request_dap() # do immediate request for dap update
def set_data(self, x: list | np.ndarray, y: list | np.ndarray):
"""
Set the data of the curve.
Args:
x(list|np.ndarray): The x data.
y(list|np.ndarray): The y data.
Raises:
ValueError: If the source is not custom.
"""
def set_data(self, x, y):
if self.config.source == "custom":
self.setData(x, y)
else:
@@ -232,7 +181,7 @@ class Curve(BECConnector, pg.PlotDataItem):
else:
logger.warning(f"Warning: '{key}' is not a recognized property.")
def set_color(self, color: str, symbol_color: str | None = None):
def set_color(self, color: str, symbol_color: Optional[str] = None):
"""
Change the color of the curve.
@@ -304,7 +253,7 @@ class Curve(BECConnector, pg.PlotDataItem):
"""
self.config.color_map_z = colormap
self.apply_config()
self.parent_item.update_with_scan_history(-1)
self.parent_item.scan_history(-1)
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
@@ -319,13 +268,10 @@ class Curve(BECConnector, pg.PlotDataItem):
return x_data, y_data
def clear_data(self):
"""
Clear the data of the curve.
"""
self.setData([], [])
def remove(self):
"""Remove the curve from the plot."""
# self.parent_item.removeItem(self)
self.parent_item.remove_curve(self.name())
super().remove()
self.rpc_register.remove_rpc(self)

View File

@@ -34,6 +34,7 @@ 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

@@ -1,15 +0,0 @@
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,42 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Form</class>
<widget class="QWidget" name="Form">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>824</width>
<height>1234</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="Waveform" name="waveform"/>
</item>
<item>
<widget class="BECDockArea" name="dock_area_2"/>
</item>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>Waveform</class>
<extends>QWidget</extends>
<header>waveform</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,262 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>1139</height>
</rect>
</property>
<property name="windowTitle">
<string>MainWindow</string>
</property>
<property name="tabShape">
<enum>QTabWidget::TabShape::Rounded</enum>
</property>
<widget class="QWidget" name="centralwidget">
<layout class="QVBoxLayout" name="verticalLayout_3">
<item>
<widget class="QTabWidget" name="central_tab">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="dock_area_tab">
<attribute name="title">
<string>Dock Area</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="BECDockArea" name="dock_area"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="vscode_tab">
<attribute name="icon">
<iconset theme="QIcon::ThemeIcon::Computer"/>
</attribute>
<attribute name="title">
<string>Visual Studio Code</string>
</attribute>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>1</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="VSCodeEditor" name="vscode"/>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1718</width>
<height>31</height>
</rect>
</property>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>Help</string>
</property>
<addaction name="action_BEC_docs"/>
<addaction name="action_BEC_widgets_docs"/>
<addaction name="action_bug_report"/>
</widget>
<widget class="QMenu" name="menuTheme">
<property name="title">
<string>Theme</string>
</property>
<addaction name="action_light"/>
<addaction name="action_dark"/>
</widget>
<addaction name="menuTheme"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar"/>
<widget class="QDockWidget" name="dock_scan_control">
<property name="windowTitle">
<string>Scan Control</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_2">
<layout class="QVBoxLayout" name="verticalLayout_4">
<item>
<widget class="ScanControl" name="scan_control"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_status_2">
<property name="windowTitle">
<string>BEC Service Status</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_3">
<layout class="QVBoxLayout" name="verticalLayout_5">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECStatusBox" name="bec_status_box_2"/>
</item>
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="dock_queue">
<property name="windowTitle">
<string>Scan Queue</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="dockWidgetContents_4">
<layout class="QVBoxLayout" name="verticalLayout_6">
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="BECQueue" name="bec_queue">
<row/>
<column/>
<column/>
<column/>
<item row="0" column="0"/>
<item row="0" column="1"/>
<item row="0" column="2"/>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="action_BEC_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Docs</string>
</property>
</action>
<action name="action_BEC_widgets_docs">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogQuestion"/>
</property>
<property name="text">
<string>BEC Widgets Docs</string>
</property>
</action>
<action name="action_bug_report">
<property name="icon">
<iconset theme="QIcon::ThemeIcon::DialogError"/>
</property>
<property name="text">
<string>Bug Report</string>
</property>
</action>
<action name="action_light">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Light</string>
</property>
</action>
<action name="action_dark">
<property name="checkable">
<bool>true</bool>
</property>
<property name="text">
<string>Dark</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>WebsiteWidget</class>
<extends>QWebEngineView</extends>
<header>website_widget</header>
</customwidget>
<customwidget>
<class>BECQueue</class>
<extends>QTableWidget</extends>
<header>bec_queue</header>
</customwidget>
<customwidget>
<class>ScanControl</class>
<extends>QWidget</extends>
<header>scan_control</header>
</customwidget>
<customwidget>
<class>VSCodeEditor</class>
<extends>WebsiteWidget</extends>
<header>vs_code_editor</header>
</customwidget>
<customwidget>
<class>BECStatusBox</class>
<extends>QWidget</extends>
<header>bec_status_box</header>
</customwidget>
<customwidget>
<class>BECDockArea</class>
<extends>QWidget</extends>
<header>dock_area</header>
</customwidget>
<customwidget>
<class>QWebEngineView</class>
<extends></extends>
<header location="global">QtWebEngineWidgets/QWebEngineView</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,180 +1,41 @@
import os
from qtpy.QtWidgets import QApplication, QMainWindow
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.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
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
from bec_widgets.utils import BECConnector
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
class BECMainWindow(BECWidget, QMainWindow):
RPC = False
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
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)
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)
self.app = QApplication.instance()
self.setWindowTitle(window_title)
self._init_ui()
# 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
def _init_ui(self):
# 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):
server_id = self.bec_dispatcher.cli_server.gui_id
self.statusBar().showMessage(f"App ID: {server_id}")
def _fetch_theme(self) -> str:
return self.app.theme.theme
def _setup_menu_bar(self):
"""
Setup the menu bar for the main window.
"""
menu_bar = self.menuBar()
########################################
# 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):
central_widget = self.centralWidget()
central_widget.close()
central_widget.deleteLater()
super().cleanup()
class WindowWithUi(BECMainWindow):
"""
This is just testing app wiht UI file which could be connected to RPC.
"""
USER_ACCESS = ["new_dock_area", "all_connections", "change_theme", "hierarchy"]
def __init__(self, *args, name: str = None, **kwargs):
super().__init__(gui_id="test", *args, **kwargs)
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__
ui_file_path = os.path.join(os.path.dirname(__file__), "example_app.ui")
self.load_ui(ui_file_path)
def load_ui(self, ui_file):
loader = UILoader(self)
self.ui = loader.loader(ui_file)
self.setCentralWidget(self.ui)
@property
def all_connections(self) -> list:
all_connections = self.rpc_register.list_all_connections()
all_connections_keys = list(all_connections.keys())
return all_connections_keys
def hierarchy(self):
WidgetHierarchy.print_widget_hierarchy(self, only_bec_widgets=True)
if __name__ == "__main__":
import sys
app = QApplication(sys.argv)
print(id(app))
# app = BECApplication(sys.argv)
# print(id(app))
main_window = WindowWithUi()
main_window.show()
sys.exit(app.exec())
def new_dock_area(self, name):
dock_area = BECDockArea()
dock_area.resize(dock_area.minimumSizeHint())
dock_area.window().setWindowTitle(name)
dock_area.show()
return dock_area

View File

@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
class AbortButton(BECWidget, QWidget):
@@ -11,7 +11,6 @@ class AbortButton(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "cancel"
RPC = False
def __init__(
self,
@@ -23,7 +22,9 @@ class AbortButton(BECWidget, QWidget):
scan_id=None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QMessageBox, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
class ResetButton(BECWidget, QWidget):
@@ -11,10 +11,11 @@ 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__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()
self.layout = QHBoxLayout(self)

View File

@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
class ResumeButton(BECWidget, QWidget):
@@ -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__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -2,8 +2,8 @@ from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QHBoxLayout, QPushButton, QSizePolicy, QToolButton, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeSlot
class StopButton(BECWidget, QWidget):
@@ -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__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.get_bec_shortcuts()

View File

@@ -13,8 +13,8 @@ class PositionIndicator(BECWidget, QWidget):
ICON_NAME = "horizontal_distribute"
def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.position = 50
self.min_value = 0
self.max_value = 100

View File

@@ -1,5 +1,6 @@
import uuid
from abc import abstractmethod
from ast import Tuple
from typing import Callable, TypedDict
from bec_lib.device import Positioner
@@ -16,8 +17,8 @@ from qtpy.QtWidgets import (
QVBoxLayout,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.widgets.control.device_control.position_indicator.position_indicator import (
PositionIndicator,
)
@@ -47,7 +48,6 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
current_path = ""
ICON_NAME = "switch_right"
RPC = False
def __init__(self, parent=None, **kwargs):
"""Initialize the PositionerBox widget.
@@ -56,7 +56,8 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
parent: The parent widget.
device (Positioner): The device to control.
"""
super().__init__(parent=parent, layout=QVBoxLayout, **kwargs)
super().__init__(**kwargs)
CompactPopupWidget.__init__(self, parent=parent, layout=QVBoxLayout)
self._dialog = None
self.get_bec_shortcuts()
@@ -139,12 +140,10 @@ class PositionerBoxBase(BECWidget, CompactPopupWidget):
if setpoint_val is not None:
break
if f"{device}_motor_done_move" in signals:
is_moving = not signals[f"{device}_motor_done_move"].get("value")
elif f"{device}_motor_is_moving" in signals:
is_moving = signals[f"{device}_motor_is_moving"].get("value")
else:
is_moving = None
for moving_signal in ["motor_done_move", "motor_is_moving"]:
is_moving = signals.get(f"{device}_{moving_signal}", {}).get("value")
if is_moving is not None:
break
if is_moving is not None:
spinner.setVisible(True)

View File

@@ -1,4 +1,4 @@
"""Module for a PositionerBox widget to control a positioner device."""
""" Module for a PositionerBox widget to control a positioner device."""
from __future__ import annotations
@@ -11,9 +11,9 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import get_accent_colors, set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,
@@ -212,34 +212,12 @@ class PositionerBox(PositionerBoxBase):
@SafeSlot()
def on_tweak_right(self):
"""Tweak motor right"""
setpoint = self._get_setpoint()
if setpoint is None:
self.dev[self.device].move(self.step_size, relative=True)
return
target = setpoint + self.step_size
self.dev[self.device].move(target, relative=False)
self.dev[self.device].move(self.step_size, relative=True)
@SafeSlot()
def on_tweak_left(self):
"""Tweak motor left"""
setpoint = self._get_setpoint()
if setpoint is None:
self.dev[self.device].move(-self.step_size, relative=True)
return
target = setpoint - self.step_size
self.dev[self.device].move(target, relative=False)
def _get_setpoint(self) -> float | None:
"""Get the setpoint of the motor"""
setpoint = getattr(self.dev[self.device], "setpoint", None)
if not setpoint:
setpoint = getattr(self.dev[self.device], "user_setpoint", None)
if not setpoint:
return None
try:
return float(setpoint.get())
except Exception:
return None
self.dev[self.device].move(-self.step_size, relative=True)
@SafeSlot()
def on_setpoint_change(self):

View File

@@ -12,9 +12,9 @@ from qtpy.QtCore import Signal
from qtpy.QtGui import QDoubleValidator
from qtpy.QtWidgets import QDoubleSpinBox
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box._base import PositionerBoxBase
from bec_widgets.widgets.control.device_control.positioner_box._base.positioner_box_base import (
DeviceUpdateUIComponents,

View File

@@ -7,8 +7,8 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Signal
from qtpy.QtWidgets import QGridLayout, QGroupBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
logger = bec_logger.logger
@@ -69,7 +69,8 @@ class PositionerGroup(BECWidget, QWidget):
Args:
parent: The parent widget.
"""
super().__init__(parent=parent, **kwargs)
super().__init__(**kwargs)
QWidget.__init__(self, parent)
self.get_bec_shortcuts()

View File

@@ -5,7 +5,6 @@ import enum
from bec_lib.device import ComputedSignal, Device, Positioner, ReadoutPriority
from bec_lib.device import Signal as BECSignal
from bec_lib.logger import bec_logger
from pydantic import field_validator
from qtpy.QtCore import Property, Signal, Slot
from bec_widgets.utils import ConnectionConfig
@@ -26,35 +25,13 @@ class BECDeviceFilter(enum.Enum):
class DeviceInputConfig(ConnectionConfig):
device_filter: list[str] = []
readout_filter: list[str] = []
device_filter: list[BECDeviceFilter] = []
readout_filter: list[ReadoutPriority] = []
devices: list[str] = []
default: str | None = None
arg_name: str | None = None
apply_filter: bool = True
@field_validator("device_filter")
@classmethod
def check_device_filter(cls, v, values):
valid_device_filters = [entry.value for entry in BECDeviceFilter]
for filt in v:
if filt not in valid_device_filters:
raise ValueError(
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
)
return v
@field_validator("readout_filter")
@classmethod
def check_readout_filter(cls, v, values):
valid_device_filters = [entry.value for entry in ReadoutPriority]
for filt in v:
if filt not in valid_device_filters:
raise ValueError(
f"Device filter {filt} is not a valid device filter {valid_device_filters}."
)
return v
class DeviceInputBase(BECWidget):
"""

View File

@@ -29,7 +29,6 @@ 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,7 +47,8 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
arg_name: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QComboBox.__init__(self, parent=parent)
if arg_name is not None:
self.config.arg_name = arg_name
self.arg_name = arg_name
@@ -103,7 +104,6 @@ class DeviceComboBox(DeviceInputBase, QComboBox):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
super().cleanup()
def get_current_device(self) -> object:
"""

View File

@@ -53,7 +53,8 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
self._callback_id = None
self._is_valid_input = False
self._accent_colors = get_accent_colors()
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QLineEdit.__init__(self, parent=parent)
self.completer = QCompleter(self)
self.setCompleter(self.completer)
@@ -110,7 +111,6 @@ class DeviceLineEdit(DeviceInputBase, QLineEdit):
"""Cleanup the widget."""
if self._callback_id is not None:
self.bec_dispatcher.client.callbacks.remove(self._callback_id)
super().cleanup()
def get_current_device(self) -> object:
"""

View File

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

View File

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

View File

@@ -18,10 +18,10 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.widgets.control.buttons.stop_button.stop_button import StopButton
from bec_widgets.widgets.control.scan_control.scan_group_box import ScanGroupBox
from bec_widgets.widgets.editors.scan_metadata.scan_metadata import ScanMetadata
@@ -65,7 +65,8 @@ class ScanControl(BECWidget, QWidget):
config = ScanControlConfig(
widget_class=self.__class__.__name__, allowed_scans=allowed_scans
)
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, gui_id=gui_id, config=config, **kwargs)
QWidget.__init__(self, parent=parent)
self._hide_add_remove_buttons = False

View File

@@ -44,7 +44,8 @@ class DapComboBox(BECWidget, QWidget):
default_fit: str | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, gui_id=gui_id, **kwargs)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.layout = QVBoxLayout(self)
self.fit_model_combobox = QComboBox(self)
self.layout.addWidget(self.fit_model_combobox)

View File

@@ -4,10 +4,10 @@ from bec_lib.logger import bec_logger
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
logger = bec_logger.logger
@@ -17,7 +17,6 @@ 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)
@@ -44,8 +43,10 @@ class LMFitDialog(BECWidget, QWidget):
gui_id (str): GUI ID.
ui_file (str): The UI file to be loaded.
"""
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
self.setProperty("skip_settings", True)
self.setObjectName("LMFitDialog")
self._ui_file = ui_file
self.target_widget = target_widget

View File

@@ -6,12 +6,12 @@
<rect>
<x>0</x>
<y>0</y>
<width>337</width>
<height>552</height>
<width>303</width>
<height>457</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -35,17 +35,11 @@
<item>
<widget class="QGroupBox" name="group_curve_selection">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>100</height>
</size>
</property>
<property name="title">
<string>Select Curve</string>
</property>
@@ -66,7 +60,7 @@
<item>
<widget class="QGroupBox" name="group_summary">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -74,7 +68,7 @@
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
<height>0</height>
</size>
</property>
<property name="title">
@@ -119,7 +113,7 @@
<item>
<widget class="QGroupBox" name="group_parameters">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Expanding">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
@@ -127,7 +121,7 @@
<property name="minimumSize">
<size>
<width>0</width>
<height>200</height>
<height>0</height>
</size>
</property>
<property name="title">

View File

@@ -25,7 +25,7 @@ from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtGui import QClipboard, QColor, QPalette, QTextCursor
from qtpy.QtWidgets import QApplication, QHBoxLayout, QScrollBar, QSizePolicy
from bec_widgets.utils.error_popups import SafeSlot as Slot
from bec_widgets.qt_utils.error_popups import SafeSlot as Slot
ansi_colors = {
"black": "#000000",

View File

@@ -13,7 +13,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.qt_utils.error_popups import SafeSlot
class AdditionalMetadataTableModel(QAbstractTableModel):

View File

@@ -20,10 +20,10 @@ from qtpy.QtWidgets import (
QWidget,
)
from bec_widgets.qt_utils.compact_popup import CompactPopupWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.expandable_frame import ExpandableGroupFrame
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.compact_popup import CompactPopupWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
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.additional_metadata_table import (
AdditionalMetadataTable,
@@ -42,7 +42,6 @@ class ScanMetadata(BECWidget, QWidget):
metadata_updated = Signal(dict)
metadata_cleared = Signal(NoneType)
RPC = False
def __init__(
self,
@@ -52,7 +51,8 @@ class ScanMetadata(BECWidget, QWidget):
initial_extras: list[list[str]] | None = None,
**kwargs,
):
super().__init__(parent=parent, client=client, **kwargs)
super().__init__(client=client, **kwargs)
QWidget.__init__(self, parent=parent)
self.set_schema(scan_name)

View File

@@ -7,9 +7,9 @@ from bec_lib.logger import bec_logger
from pydantic import Field
from qtpy.QtWidgets import QTextEdit, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.bec_connector import ConnectionConfig
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
logger = bec_logger.logger
@@ -49,7 +49,8 @@ class TextBox(BECWidget, QWidget):
if isinstance(config, dict):
config = TextBoxConfig(**config)
self.config = config
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.text_box_text_edit = QTextEdit(parent=self)
self.layout.addWidget(self.text_box_text_edit)

View File

@@ -26,7 +26,8 @@ class WebsiteWidget(BECWidget, QWidget):
def __init__(
self, parent=None, url: str = None, config=None, client=None, gui_id=None, **kwargs
):
super().__init__(parent=parent, client=client, gui_id=gui_id, config=config, **kwargs)
super().__init__(client=client, config=config, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent=parent)
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__(parent=parent, *args, **kwargs)
super().__init__(*args, **kwargs)
QWidget.__init__(self, parent=parent)
self._ui_initialised = False
self._timer_start_num_seconds = 0
@@ -403,7 +403,6 @@ class Minesweeper(BECWidget, QWidget):
def cleanup(self):
self._timer.stop()
super().cleanup()
if __name__ == "__main__":

View File

@@ -0,0 +1 @@
{'files': ['image_widget.py']}

View File

@@ -1,39 +1,43 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
DOM_XML = """
<ui language='c++'>
<widget class='Waveform' name='waveform'>
<widget class='BECImageWidget' name='bec_image_widget'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class BECImageWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Waveform(parent)
t = BECImageWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets"
return "BEC Plots"
def icon(self):
return designer_material_icon(Waveform.ICON_NAME)
return designer_material_icon(BECImageWidget.ICON_NAME)
def includeFile(self):
return "waveform"
return "bec_image_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -45,10 +49,10 @@ class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "Waveform"
return "BECImageWidget"
def toolTip(self):
return "Waveform"
return "BECImageWidget"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,948 +0,0 @@
from __future__ import annotations
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import QPointF, Signal
from qtpy.QtWidgets import QWidget
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.widgets.plots.image.image_item import ImageItem
from bec_widgets.widgets.plots.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.image.toolbar_bundles.processing import ImageProcessingToolbarBundle
from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger
# noinspection PyDataclass
class ImageConfig(ConnectionConfig):
color_map: str = Field(
"magma", description="The colormap of the figure widget.", validate_default=True
)
color_bar: Literal["full", "simple"] | None = Field(
None, description="The type of the color bar."
)
lock_aspect_ratio: bool = Field(
False, description="Whether to lock the aspect ratio of the image."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map = field_validator("color_map")(Colors.validate_color_map)
class Image(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "image"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
# ImageView Specific Settings
"color_map",
"color_map.setter",
"vrange",
"vrange.setter",
"v_min",
"v_min.setter",
"v_max",
"v_max.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"autorange",
"autorange.setter",
"autorange_mode",
"autorange_mode.setter",
"monitor",
"monitor.setter",
"enable_colorbar",
"enable_simple_colorbar",
"enable_simple_colorbar.setter",
"enable_full_colorbar",
"enable_full_colorbar.setter",
"fft",
"fft.setter",
"log",
"log.setter",
"rotation",
"rotation.setter",
"transpose",
"transpose.setter",
"image",
"main_image",
]
sync_colorbar_with_autorange = Signal()
def __init__(
self,
parent: QWidget | None = None,
config: ImageConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = True,
**kwargs,
):
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
)
self._main_image.parent_image = self
self.plot_item.addItem(self._main_image)
self.scan_id = None
# Default Color map to magma
self.color_map = "magma"
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_toolbar(self):
# add to the first position
self.selection_bundle = MonitorSelectionToolbarBundle(
bundle_id="selection", target_widget=self
)
self.toolbar.add_bundle(self.selection_bundle, self)
super()._init_toolbar()
# Image specific changes to PlotBase toolbar
self.toolbar.widgets["reset_legend"].action.setVisible(False)
# Lock aspect ratio button
self.lock_aspect_ratio_action = MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock Aspect Ratio", checkable=True, parent=self
)
self.toolbar.add_action_to_bundle(
bundle_id="mouse_interaction",
action_id="lock_aspect_ratio",
action=self.lock_aspect_ratio_action,
target_widget=self,
)
self.lock_aspect_ratio_action.action.toggled.connect(
lambda checked: self.setProperty("lock_aspect_ratio", checked)
)
self.lock_aspect_ratio_action.action.setChecked(True)
self._init_autorange_action()
self._init_colorbar_action()
# Processing Bundle
self.processing_bundle = ImageProcessingToolbarBundle(
bundle_id="processing", target_widget=self
)
self.toolbar.add_bundle(self.processing_bundle, target_widget=self)
def _init_autorange_action(self):
self.autorange_mean_action = MaterialIconAction(
icon_name="hdr_auto", tooltip="Enable Auto Range (Mean)", checkable=True, parent=self
)
self.autorange_max_action = MaterialIconAction(
icon_name="hdr_auto",
tooltip="Enable Auto Range (Max)",
checkable=True,
filled=True,
parent=self,
)
self.autorange_switch = SwitchableToolBarAction(
actions={
"auto_range_mean": self.autorange_mean_action,
"auto_range_max": self.autorange_max_action,
},
initial_action="auto_range_mean",
tooltip="Enable Auto Range",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="autorange_image",
action=self.autorange_switch,
target_widget=self,
)
self.autorange_mean_action.action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="mean")
)
self.autorange_max_action.action.toggled.connect(
lambda checked: self.toggle_autorange(checked, mode="max")
)
self.autorange = True
self.autorange_mode = "mean"
def _init_colorbar_action(self):
self.full_colorbar_action = MaterialIconAction(
icon_name="edgesensor_low", tooltip="Enable Full Colorbar", checkable=True, parent=self
)
self.simple_colorbar_action = MaterialIconAction(
icon_name="smartphone", tooltip="Enable Simple Colorbar", checkable=True, parent=self
)
self.colorbar_switch = SwitchableToolBarAction(
actions={
"full_colorbar": self.full_colorbar_action,
"simple_colorbar": self.simple_colorbar_action,
},
initial_action="full_colorbar",
tooltip="Enable Full Colorbar",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="switch_colorbar",
action=self.colorbar_switch,
target_widget=self,
)
self.simple_colorbar_action.action.toggled.connect(
lambda checked: self.enable_colorbar(checked, style="simple")
)
self.full_colorbar_action.action.toggled.connect(
lambda checked: self.enable_colorbar(checked, style="full")
)
def enable_colorbar(
self,
enabled: bool,
style: Literal["full", "simple"] = "full",
vrange: tuple[int, int] | None = None,
):
"""
Enable the colorbar and switch types of colorbars.
Args:
enabled(bool): Whether to enable the colorbar.
style(Literal["full", "simple"]): The type of colorbar to enable.
vrange(tuple): The range of values to use for the colorbar.
"""
autorange_state = self._main_image.autorange
if enabled:
if self._color_bar:
if self.config.color_bar == "full":
self.cleanup_histogram_lut_item(self._color_bar)
self.plot_widget.removeItem(self._color_bar)
self._color_bar = None
if style == "simple":
self._color_bar = pg.ColorBarItem(colorMap=self.config.color_map)
self._color_bar.setImageItem(self._main_image)
self._color_bar.sigLevelsChangeFinished.connect(
lambda: self.setProperty("autorange", False)
)
elif style == "full":
self._color_bar = pg.HistogramLUTItem()
self._color_bar.setImageItem(self._main_image)
self._color_bar.gradient.loadPreset(self.config.color_map)
self._color_bar.sigLevelsChanged.connect(
lambda: self.setProperty("autorange", False)
)
self.plot_widget.addItem(self._color_bar, row=0, col=1)
self.config.color_bar = style
else:
if self._color_bar:
self.plot_widget.removeItem(self._color_bar)
self._color_bar = None
self.config.color_bar = None
self.autorange = autorange_state
self._sync_colorbar_actions()
if vrange: # should be at the end to disable the autorange if defined
self.v_range = vrange
################################################################################
# Widget Specific Properties
################################################################################
################################################################################
# Colorbar toggle
@SafeProperty(bool)
def enable_simple_colorbar(self) -> bool:
"""
Enable the simple colorbar.
"""
enabled = False
if self.config.color_bar == "simple":
enabled = True
return enabled
@enable_simple_colorbar.setter
def enable_simple_colorbar(self, value: bool):
"""
Enable the simple colorbar.
Args:
value(bool): Whether to enable the simple colorbar.
"""
self.enable_colorbar(enabled=value, style="simple")
@SafeProperty(bool)
def enable_full_colorbar(self) -> bool:
"""
Enable the full colorbar.
"""
enabled = False
if self.config.color_bar == "full":
enabled = True
return enabled
@enable_full_colorbar.setter
def enable_full_colorbar(self, value: bool):
"""
Enable the full colorbar.
Args:
value(bool): Whether to enable the full colorbar.
"""
self.enable_colorbar(enabled=value, style="full")
################################################################################
# Appearance
@SafeProperty(str)
def color_map(self) -> str:
"""
Set the color map of the image.
"""
return self.config.color_map
@color_map.setter
def color_map(self, value: str):
"""
Set the color map of the image.
Args:
value(str): The color map to set.
"""
try:
self.config.color_map = value
self._main_image.color_map = value
if self._color_bar:
if self.config.color_bar == "simple":
self._color_bar.setColorMap(value)
elif self.config.color_bar == "full":
self._color_bar.gradient.loadPreset(value)
except ValidationError:
return
# v_range is for designer, vrange is for RPC
@SafeProperty("QPointF")
def v_range(self) -> QPointF:
"""
Set the v_range of the main image.
"""
vmin, vmax = self._main_image.v_range
return QPointF(vmin, vmax)
@v_range.setter
def v_range(self, value: tuple | list | QPointF):
"""
Set the v_range of the main image.
Args:
value(tuple | list | QPointF): The range of values to set.
"""
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
vmin, vmax = value.x(), value.y()
self._main_image.v_range = (vmin, vmax)
# propagate to colorbar if exists
if self._color_bar:
if self.config.color_bar == "simple":
self._color_bar.setLevels(low=vmin, high=vmax)
elif self.config.color_bar == "full":
self._color_bar.setLevels(min=vmin, max=vmax)
self._color_bar.setHistogramRange(vmin - 0.1 * vmin, vmax + 0.1 * vmax)
self.autorange_switch.set_state_all(False)
@property
def vrange(self) -> tuple:
"""
Get the vrange of the image.
"""
return (self.v_range.x(), self.v_range.y())
@vrange.setter
def vrange(self, value):
"""
Set the vrange of the image.
Args:
value(tuple):
"""
self.v_range = value
@property
def v_min(self) -> float:
"""
Get the minimum value of the v_range.
"""
return self.v_range.x()
@v_min.setter
def v_min(self, value: float):
"""
Set the minimum value of the v_range.
Args:
value(float): The minimum value to set.
"""
self.v_range = (value, self.v_range.y())
@property
def v_max(self) -> float:
"""
Get the maximum value of the v_range.
"""
return self.v_range.y()
@v_max.setter
def v_max(self, value: float):
"""
Set the maximum value of the v_range.
Args:
value(float): The maximum value to set.
"""
self.v_range = (self.v_range.x(), value)
@SafeProperty(bool)
def lock_aspect_ratio(self) -> bool:
"""
Whether the aspect ratio is locked.
"""
return self.config.lock_aspect_ratio
@lock_aspect_ratio.setter
def lock_aspect_ratio(self, value: bool):
"""
Set the aspect ratio lock.
Args:
value(bool): Whether to lock the aspect ratio.
"""
self.config.lock_aspect_ratio = bool(value)
self.plot_item.setAspectLocked(value)
################################################################################
# Data Acquisition
@SafeProperty(str)
def monitor(self) -> str:
"""
The name of the monitor to use for the image.
"""
return self._main_image.config.monitor
@monitor.setter
def monitor(self, value: str):
"""
Set the monitor for the image.
Args:
value(str): The name of the monitor to set.
"""
if self._main_image.config.monitor == value:
return
try:
self.entry_validator.validate_monitor(value)
except ValueError:
return
self.image(monitor=value)
@property
def main_image(self) -> ImageItem:
"""Access the main image item."""
return self._main_image
################################################################################
# Autorange + Colorbar sync
@SafeProperty(bool)
def autorange(self) -> bool:
"""
Whether autorange is enabled.
"""
return self._main_image.autorange
@autorange.setter
def autorange(self, enabled: bool):
"""
Set autorange.
Args:
enabled(bool): Whether to enable autorange.
"""
self._main_image.autorange = enabled
if enabled and self._main_image.raw_data is not None:
self._main_image.apply_autorange()
self._sync_colorbar_levels()
self._sync_autorange_switch()
@SafeProperty(str)
def autorange_mode(self) -> str:
"""
Autorange mode.
Options:
- "max": Use the maximum value of the image for autoranging.
- "mean": Use the mean value of the image for autoranging.
"""
return self._main_image.autorange_mode
@autorange_mode.setter
def autorange_mode(self, mode: str):
"""
Set the autorange mode.
Args:
mode(str): The autorange mode. Options are "max" or "mean".
"""
# for qt Designer
if mode not in ["max", "mean"]:
return
self._main_image.autorange_mode = mode
self._sync_autorange_switch()
@SafeSlot(bool, str, bool)
def toggle_autorange(self, enabled: bool, mode: str):
"""
Toggle autorange.
Args:
enabled(bool): Whether to enable autorange.
mode(str): The autorange mode. Options are "max" or "mean".
"""
if self._main_image is not None:
self._main_image.autorange = enabled
self._main_image.autorange_mode = mode
if enabled:
self._main_image.apply_autorange()
self._sync_colorbar_levels()
def _sync_autorange_switch(self):
"""
Synchronize the autorange switch with the current autorange state and mode if changed from outside.
"""
self.autorange_switch.block_all_signals(True)
self.autorange_switch.set_default_action(f"auto_range_{self._main_image.autorange_mode}")
self.autorange_switch.set_state_all(self._main_image.autorange)
self.autorange_switch.block_all_signals(False)
def _sync_colorbar_levels(self):
"""Immediately propagate current levels to the active colorbar."""
vrange = self._main_image.v_range
if self._color_bar:
self._color_bar.blockSignals(True)
self.v_range = vrange
self._color_bar.blockSignals(False)
def _sync_colorbar_actions(self):
"""
Synchronize the colorbar actions with the current colorbar state.
"""
self.colorbar_switch.block_all_signals(True)
if self._color_bar is not None:
self.colorbar_switch.set_default_action(f"{self.config.color_bar}_colorbar")
self.colorbar_switch.set_state_all(True)
else:
self.colorbar_switch.set_state_all(False)
self.colorbar_switch.block_all_signals(False)
################################################################################
# Post Processing
################################################################################
@SafeProperty(bool)
def fft(self) -> bool:
"""
Whether FFT postprocessing is enabled.
"""
return self._main_image.fft
@fft.setter
def fft(self, enable: bool):
"""
Set FFT postprocessing.
Args:
enable(bool): Whether to enable FFT postprocessing.
"""
self._main_image.fft = enable
@SafeProperty(bool)
def log(self) -> bool:
"""
Whether logarithmic scaling is applied.
"""
return self._main_image.log
@log.setter
def log(self, enable: bool):
"""
Set logarithmic scaling.
Args:
enable(bool): Whether to enable logarithmic scaling.
"""
self._main_image.log = enable
@SafeProperty(int)
def rotation(self) -> int:
"""
The number of 90° rotations to apply.
"""
return self._main_image.rotation
@rotation.setter
def rotation(self, value: int):
"""
Set the number of 90° rotations to apply.
Args:
value(int): The number of 90° rotations to apply.
"""
self._main_image.rotation = value
@SafeProperty(bool)
def transpose(self) -> bool:
"""
Whether the image is transposed.
"""
return self._main_image.transpose
@transpose.setter
def transpose(self, enable: bool):
"""
Set the image to be transposed.
Args:
enable(bool): Whether to enable transposing the image.
"""
self._main_image.transpose = enable
################################################################################
# High Level methods for API
################################################################################
@SafeSlot(popup_error=True)
def image(
self,
monitor: str | None = None,
monitor_type: Literal["auto", "1d", "2d"] = "auto",
color_map: str | None = None,
color_bar: Literal["simple", "full"] | None = None,
vrange: tuple[int, int] | None = None,
) -> ImageItem:
"""
Set the image source and update the image.
Args:
monitor(str): The name of the monitor to use for the image.
monitor_type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
color_map(str): The color map to use for the image.
color_bar(str): The type of color bar to use. Options are "simple" or "full".
vrange(tuple): The range of values to use for the color map.
Returns:
ImageItem: The image object.
"""
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
self.entry_validator.validate_monitor(monitor)
self._main_image.config.monitor = monitor
if monitor_type == "1d":
self._main_image.config.source = "device_monitor_1d"
self._main_image.config.monitor_type = "1d"
elif monitor_type == "2d":
self._main_image.config.source = "device_monitor_2d"
self._main_image.config.monitor_type = "2d"
elif monitor_type == "auto":
self._main_image.config.source = "auto"
logger.warning(
f"Updates for '{monitor}' will be fetch from both 1D and 2D monitor endpoints."
)
self._main_image.config.monitor_type = "auto"
self.set_image_update(monitor=monitor, type=monitor_type)
if color_map is not None:
self._main_image.color_map = color_map
if color_bar is not None:
self.enable_colorbar(True, color_bar)
if vrange is not None:
self.vrange = vrange
self._sync_device_selection()
return self._main_image
def _sync_device_selection(self):
"""
Synchronize the device selection with the current monitor.
"""
if self._main_image.config.monitor is not None:
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(True)
self.selection_bundle.device_combo_box.set_device(self._main_image.config.monitor)
self.selection_bundle.dim_combo_box.setCurrentText(self._main_image.config.monitor_type)
for combo in (
self.selection_bundle.device_combo_box,
self.selection_bundle.dim_combo_box,
):
combo.blockSignals(False)
################################################################################
# Image Update Methods
################################################################################
########################################
# Connections
def set_image_update(self, monitor: str, type: Literal["1d", "2d", "auto"]):
"""
Set the image update method for the given monitor.
Args:
monitor(str): The name of the monitor to use for the image.
type(str): The type of monitor to use. Options are "1d", "2d", or "auto".
"""
# TODO consider moving connecting and disconnecting logic to Image itself if multiple images
if type == "1d":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
elif type == "2d":
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
elif type == "auto":
self.bec_dispatcher.connect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.connect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
print(f"Connected to {monitor} with type {type}")
self._main_image.config.monitor = monitor
def disconnect_monitor(self, monitor: str):
"""
Disconnect the monitor from the image update signals, both 1D and 2D.
Args:
monitor(str): The name of the monitor to disconnect.
"""
self.bec_dispatcher.disconnect_slot(
self.on_image_update_1d, MessageEndpoints.device_monitor_1d(monitor)
)
self.bec_dispatcher.disconnect_slot(
self.on_image_update_2d, MessageEndpoints.device_monitor_2d(monitor)
)
self._main_image.config.monitor = None
########################################
# 1D updates
@SafeSlot(dict, dict)
def on_image_update_1d(self, msg: dict, metadata: dict):
"""
Update the image with 1D data.
Args:
msg(dict): The message containing the data.
metadata(dict): The metadata associated with the message.
"""
data = msg["data"]
current_scan_id = metadata.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.scan_id = current_scan_id
self._main_image.clear()
self._main_image.buffer = []
self._main_image.max_len = 0
image_buffer = self.adjust_image_buffer(self._main_image, data)
if self._color_bar is not None:
self._color_bar.blockSignals(True)
self._main_image.set_data(image_buffer)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
def adjust_image_buffer(self, image: ImageItem, new_data: np.ndarray) -> np.ndarray:
"""
Adjusts the image buffer to accommodate the new data, ensuring that all rows have the same length.
Args:
image: The image object (used to store a buffer list and max_len).
new_data (np.ndarray): The new incoming 1D waveform data.
Returns:
np.ndarray: The updated image buffer with adjusted shapes.
"""
new_len = new_data.shape[0]
if not hasattr(image, "buffer"):
image.buffer = []
image.max_len = 0
if new_len > image.max_len:
image.max_len = new_len
for i in range(len(image.buffer)):
wf = image.buffer[i]
pad_width = image.max_len - wf.shape[0]
if pad_width > 0:
image.buffer[i] = np.pad(wf, (0, pad_width), mode="constant", constant_values=0)
image.buffer.append(new_data)
else:
pad_width = image.max_len - new_len
if pad_width > 0:
new_data = np.pad(new_data, (0, pad_width), mode="constant", constant_values=0)
image.buffer.append(new_data)
image_buffer = np.array(image.buffer)
return image_buffer
########################################
# 2D updates
def on_image_update_2d(self, msg: dict, metadata: dict):
"""
Update the image with 2D data.
Args:
msg(dict): The message containing the data.
metadata(dict): The metadata associated with the message.
"""
data = msg["data"]
if self._color_bar is not None:
self._color_bar.blockSignals(True)
self._main_image.set_data(data)
if self._color_bar is not None:
self._color_bar.blockSignals(False)
################################################################################
# Clean up
################################################################################
@staticmethod
def cleanup_histogram_lut_item(histogram_lut_item: pg.HistogramLUTItem):
"""
Clean up HistogramLUTItem safely, including open ViewBox menus and child widgets.
Args:
histogram_lut_item(pg.HistogramLUTItem): The HistogramLUTItem to clean up.
"""
histogram_lut_item.vb.menu.close()
histogram_lut_item.vb.menu.deleteLater()
histogram_lut_item.gradient.menu.close()
histogram_lut_item.gradient.menu.deleteLater()
histogram_lut_item.gradient.colorDialog.close()
histogram_lut_item.gradient.colorDialog.deleteLater()
def cleanup(self):
"""
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)
if self.config.color_bar == "simple":
self.plot_widget.removeItem(self._color_bar)
self._color_bar.deleteLater()
self._color_bar = None
# Toolbar cleanup
self.toolbar.widgets["monitor"].widget.close()
self.toolbar.widgets["monitor"].widget.deleteLater()
super().cleanup()
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = Image(popups=True)
widget.show()
widget.resize(1000, 800)
sys.exit(app.exec_())

View File

@@ -1 +0,0 @@
{'files': ['image.py']}

View File

@@ -1,277 +0,0 @@
from __future__ import annotations
from typing import Literal, Optional
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal
from bec_widgets.utils import BECConnector, Colors, ConnectionConfig
from bec_widgets.widgets.plots.image.image_processor import (
ImageProcessor,
ImageStats,
ProcessingConfig,
)
logger = bec_logger.logger
# noinspection PyDataclass
class ImageItemConfig(ConnectionConfig): # TODO review config
parent_id: str | None = Field(None, description="The parent plot of the image.")
monitor: str | None = Field(None, description="The name of the monitor.")
monitor_type: Literal["1d", "2d", "auto"] = Field("auto", description="The type of monitor.")
source: str | None = Field(None, description="The source of the curve.")
color_map: str | None = Field("magma", description="The color map of the image.")
downsample: bool | None = Field(True, description="Whether to downsample the image.")
opacity: float | None = Field(1.0, description="The opacity of the image.")
v_range: tuple[float | int, float | int] | None = Field(
None, description="The range of the color bar. If None, the range is automatically set."
)
autorange: bool | None = Field(True, description="Whether to autorange the color bar.")
autorange_mode: Literal["max", "mean"] = Field(
"mean", description="Whether to use the mean of the image for autoscaling."
)
processing: ProcessingConfig = Field(
default_factory=ProcessingConfig, description="The post processing of the image."
)
model_config: dict = {"validate_assignment": True}
_validate_color_map = field_validator("color_map")(Colors.validate_color_map)
class ImageItem(BECConnector, pg.ImageItem):
RPC = True
USER_ACCESS = [
"color_map",
"color_map.setter",
"v_range",
"v_range.setter",
"v_min",
"v_min.setter",
"v_max",
"v_max.setter",
"autorange",
"autorange.setter",
"autorange_mode",
"autorange_mode.setter",
"fft",
"fft.setter",
"log",
"log.setter",
"rotation",
"rotation.setter",
"transpose",
"transpose.setter",
"get_data",
]
vRangeChangedManually = Signal(tuple)
def __init__(
self,
config: Optional[ImageItemConfig] = None,
gui_id: Optional[str] = None,
parent_image=None,
**kwargs,
):
if config is None:
config = ImageItemConfig(widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
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 = []
self.max_len = 0
# 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()
################################################################################
# Properties
@property
def color_map(self) -> str:
"""Get the current color map."""
return self.config.color_map
@color_map.setter
def color_map(self, value: str):
"""Set a new color map."""
try:
self.config.color_map = value
self.setColorMap(value)
except ValidationError:
logger.error(f"Invalid colormap '{value}' provided.")
@property
def v_range(self) -> tuple[float, float]:
"""
Get the color intensity range of the image.
"""
if self.levels is not None:
return tuple(float(x) for x in self.levels)
return 0.0, 1.0
@v_range.setter
def v_range(self, vrange: tuple[float, float]):
"""
Set the color intensity range of the image.
"""
self.set_v_range(vrange, disable_autorange=True)
def set_v_range(self, vrange: tuple[float, float], disable_autorange=True):
if disable_autorange:
self.config.autorange = False
self.vRangeChangedManually.emit(vrange)
self.setLevels(vrange)
self.config.v_range = vrange
@property
def v_min(self) -> float:
return self.v_range[0]
@v_min.setter
def v_min(self, value: float):
self.v_range = (value, self.v_range[1])
@property
def v_max(self) -> float:
return self.v_range[1]
@v_max.setter
def v_max(self, value: float):
self.v_range = (self.v_range[0], value)
################################################################################
# Autorange Logic
@property
def autorange(self) -> bool:
return self.config.autorange
@autorange.setter
def autorange(self, value: bool):
self.config.autorange = value
if value:
self.apply_autorange()
@property
def autorange_mode(self) -> str:
return self.config.autorange_mode
@autorange_mode.setter
def autorange_mode(self, mode: str):
self.config.autorange_mode = mode
if self.autorange:
self.apply_autorange()
def apply_autorange(self):
if self.raw_data is None:
return
data = self.image
if data is None:
data = self.raw_data
stats = ImageStats.from_data(data)
self.auto_update_vrange(stats)
def auto_update_vrange(self, stats: ImageStats) -> None:
"""Update the v_range based on the stats of the image."""
fumble_factor = 2
if self.config.autorange_mode == "mean":
vmin = max(stats.mean - fumble_factor * stats.std, 0)
vmax = stats.mean + fumble_factor * stats.std
elif self.config.autorange_mode == "max":
vmin, vmax = stats.minimum, stats.maximum
else:
return
self.set_v_range(vrange=(vmin, vmax), disable_autorange=False)
################################################################################
# Data Processing Logic
def _process_image(self):
"""
Reprocess the current raw data and update the image display.
"""
if self.raw_data is not None:
autorange = self.config.autorange
self._image_processor.set_config(self.config.processing)
processed_data = self._image_processor.process_image(self.raw_data)
self.setImage(processed_data, autoLevels=False)
self.autorange = autorange
@property
def fft(self) -> bool:
"""Get or set whether FFT postprocessing is enabled."""
return self.config.processing.fft
@fft.setter
def fft(self, enable: bool):
self.config.processing.fft = enable
self._process_image()
@property
def log(self) -> bool:
"""Get or set whether logarithmic scaling is applied."""
return self.config.processing.log
@log.setter
def log(self, enable: bool):
self.config.processing.log = enable
self._process_image()
@property
def rotation(self) -> Optional[int]:
"""Get or set the number of 90° rotations to apply."""
return self.config.processing.rotation
@rotation.setter
def rotation(self, value: Optional[int]):
self.config.processing.rotation = value
self._process_image()
@property
def transpose(self) -> bool:
"""Get or set whether the image is transposed."""
return self.config.processing.transpose
@transpose.setter
def transpose(self, enable: bool):
self.config.processing.transpose = enable
self._process_image()
################################################################################
# Export
def get_data(self) -> np.ndarray:
"""
Get the data of the image.
Returns:
np.ndarray: The data of the image.
"""
return self.image
def clear(self):
super().clear()
self.raw_data = None
self.buffer = []
self.max_len = 0

View File

@@ -0,0 +1,515 @@
from __future__ import annotations
import sys
from typing import Literal, Optional
import pyqtgraph as pg
from bec_lib.device import ReadoutPriority
from qtpy.QtWidgets import QComboBox, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import (
DeviceSelectionAction,
MaterialIconAction,
ModularToolBar,
SeparatorAction,
WidgetAction,
)
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
from bec_widgets.widgets.containers.figure.plots.image.image import ImageConfig
from bec_widgets.widgets.containers.figure.plots.image.image_item import BECImageItem
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
class BECImageWidget(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "image"
USER_ACCESS = [
"image",
"set",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_vrange",
"set_fft",
"set_transpose",
"set_rotation",
"set_log",
"set_grid",
"enable_fps_monitor",
"lock_aspect_ratio",
]
def __init__(
self,
parent: QWidget | None = None,
config: ImageConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = ImageConfig(**config)
super().__init__(client=client, gui_id=gui_id, **kwargs)
QWidget.__init__(self, parent)
self.layout = QVBoxLayout(self)
self.layout.setSpacing(0)
self.layout.setContentsMargins(0, 0, 0, 0)
self.fig = BECFigure()
self.dim_combo_box = QComboBox()
self.dim_combo_box.addItems(["1d", "2d"])
self.toolbar = ModularToolBar(
actions={
"monitor": DeviceSelectionAction(
"Monitor:",
DeviceComboBox(
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
),
),
"monitor_type": WidgetAction(widget=self.dim_combo_box),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Device"),
"separator_0": SeparatorAction(),
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
"separator_1": SeparatorAction(),
"drag_mode": MaterialIconAction(
icon_name="open_with", tooltip="Drag Mouse Mode", checkable=True
),
"rectangle_mode": MaterialIconAction(
icon_name="frame_inspect", tooltip="Rectangle Zoom Mode", checkable=True
),
"auto_range": MaterialIconAction(
icon_name="open_in_full", tooltip="Autorange Plot"
),
"auto_range_image": MaterialIconAction(
icon_name="hdr_auto", tooltip="Autorange Image Intensity", checkable=True
),
"aspect_ratio": MaterialIconAction(
icon_name="aspect_ratio", tooltip="Lock image aspect ratio", checkable=True
),
"separator_2": SeparatorAction(),
"FFT": MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True),
"log": MaterialIconAction(
icon_name="log_scale", tooltip="Toggle log scale", checkable=True
),
"transpose": MaterialIconAction(
icon_name="transform", tooltip="Transpose Image", checkable=True
),
"rotate_right": MaterialIconAction(
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
),
"rotate_left": MaterialIconAction(
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
),
"reset": MaterialIconAction(
icon_name="reset_settings", tooltip="Reset Image Settings"
),
"separator_3": SeparatorAction(),
"fps_monitor": MaterialIconAction(
icon_name="speed", tooltip="Show FPS Monitor", checkable=True
),
"axis_settings": MaterialIconAction(
icon_name="settings", tooltip="Open Configuration Dialog"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.fig)
self.warning_util = WarningPopupUtility(self)
self._image = self.fig.image()
self._image.apply_config(config)
self.rotation = 0
self.config = config
self._hook_actions()
self.toolbar.widgets["drag_mode"].action.setChecked(True)
self.toolbar.widgets["auto_range_image"].action.setChecked(True)
def _hook_actions(self):
self.toolbar.widgets["connect"].action.triggered.connect(self._connect_action)
# sepatator
self.toolbar.widgets["save"].action.triggered.connect(self.export)
# sepatator
self.toolbar.widgets["drag_mode"].action.triggered.connect(self.enable_mouse_pan_mode)
self.toolbar.widgets["rectangle_mode"].action.triggered.connect(
self.enable_mouse_rectangle_mode
)
self.toolbar.widgets["auto_range"].action.triggered.connect(self.toggle_auto_range)
self.toolbar.widgets["auto_range_image"].action.triggered.connect(
self.toggle_image_autorange
)
self.toolbar.widgets["aspect_ratio"].action.triggered.connect(self.toggle_aspect_ratio)
# sepatator
self.toolbar.widgets["FFT"].action.triggered.connect(self.toggle_fft)
self.toolbar.widgets["log"].action.triggered.connect(self.toggle_log)
self.toolbar.widgets["transpose"].action.triggered.connect(self.toggle_transpose)
self.toolbar.widgets["rotate_left"].action.triggered.connect(self.rotate_left)
self.toolbar.widgets["rotate_right"].action.triggered.connect(self.rotate_right)
self.toolbar.widgets["reset"].action.triggered.connect(self.reset_settings)
# sepatator
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
###################################
# Dialog Windows
###################################
@SafeSlot(popup_error=True)
def _connect_action(self):
monitor_combo = self.toolbar.widgets["monitor"].device_combobox
monitor_name = monitor_combo.currentText()
monitor_type = self.toolbar.widgets["monitor_type"].widget.currentText()
self.image(monitor=monitor_name, monitor_type=monitor_type)
monitor_combo.setStyleSheet("QComboBox { background-color: " "; }")
def show_axis_settings(self):
dialog = SettingsDialog(
self,
settings_widget=AxisSettings(),
window_title="Axis Settings",
config=self._config_dict["axis"],
)
dialog.exec()
###################################
# User Access Methods from image
###################################
@SafeSlot(popup_error=True)
def image(
self,
monitor: str,
monitor_type: Optional[Literal["1d", "2d"]] = "2d",
color_map: Optional[str] = "magma",
color_bar: Optional[Literal["simple", "full"]] = "full",
downsample: Optional[bool] = True,
opacity: Optional[float] = 1.0,
vrange: Optional[tuple[int, int]] = None,
# post_processing: Optional[PostProcessingConfig] = None,
**kwargs,
) -> BECImageItem:
if self.toolbar.widgets["monitor"].device_combobox.currentText() != monitor:
self.toolbar.widgets["monitor"].device_combobox.setCurrentText(monitor)
self.toolbar.widgets["monitor"].device_combobox.setStyleSheet(
"QComboBox {{ background-color: " "; }}"
)
if self.toolbar.widgets["monitor_type"].widget.currentText() != monitor_type:
self.toolbar.widgets["monitor_type"].widget.setCurrentText(monitor_type)
self.toolbar.widgets["monitor_type"].widget.setStyleSheet(
"QComboBox {{ background-color: " "; }}"
)
return self._image.image(
monitor=monitor,
monitor_type=monitor_type,
color_map=color_map,
color_bar=color_bar,
downsample=downsample,
opacity=opacity,
vrange=vrange,
**kwargs,
)
def set_vrange(self, vmin: float, vmax: float, name: str = None):
"""
Set the range of the color bar.
If name is not specified, then set vrange for all images.
Args:
vmin(float): Minimum value of the color bar.
vmax(float): Maximum value of the color bar.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_vrange(vmin, vmax, name)
def set_color_map(self, color_map: str, name: str = None):
"""
Set the color map of the image.
If name is not specified, then set color map for all images.
Args:
cmap(str): The color map of the image.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_color_map(color_map, name)
def set_fft(self, enable: bool = False, name: str = None):
"""
Set the FFT of the image.
If name is not specified, then set FFT for all images.
Args:
enable(bool): Whether to perform FFT on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_fft(enable, name)
self.toolbar.widgets["FFT"].action.setChecked(enable)
def set_transpose(self, enable: bool = False, name: str = None):
"""
Set the transpose of the image.
If name is not specified, then set transpose for all images.
Args:
enable(bool): Whether to transpose the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_transpose(enable, name)
self.toolbar.widgets["transpose"].action.setChecked(enable)
def set_rotation(self, deg_90: int = 0, name: str = None):
"""
Set the rotation of the image.
If name is not specified, then set rotation for all images.
Args:
deg_90(int): The rotation angle of the monitor data before displaying.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_rotation(deg_90, name)
def set_log(self, enable: bool = False, name: str = None):
"""
Set the log of the image.
If name is not specified, then set log for all images.
Args:
enable(bool): Whether to perform log on the monitor data.
name(str): The name of the image. If None, apply to all images.
"""
self._image.set_log(enable, name)
self.toolbar.widgets["log"].action.setChecked(enable)
###################################
# User Access Methods from Plotbase
###################################
def set(self, **kwargs):
"""
Set the properties of the plot widget.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- title: str
- x_label: str
- y_label: str
- x_scale: Literal["linear", "log"]
- y_scale: Literal["linear", "log"]
- x_lim: tuple
- y_lim: tuple
- legend_label_size: int
"""
self._image.set(**kwargs)
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot.
"""
self._image.set_title(title)
def set_x_label(self, x_label: str):
"""
Set the x-axis label of the plot widget.
Args:
x_label(str): Label of the x-axis.
"""
self._image.set_x_label(x_label)
def set_y_label(self, y_label: str):
"""
Set the y-axis label of the plot widget.
Args:
y_label(str): Label of the y-axis.
"""
self._image.set_y_label(y_label)
def set_x_scale(self, x_scale: Literal["linear", "log"]):
"""
Set the scale of the x-axis of the plot widget.
Args:
x_scale(Literal["linear", "log"]): Scale of the x-axis.
"""
self._image.set_x_scale(x_scale)
def set_y_scale(self, y_scale: Literal["linear", "log"]):
"""
Set the scale of the y-axis of the plot widget.
Args:
y_scale(Literal["linear", "log"]): Scale of the y-axis.
"""
self._image.set_y_scale(y_scale)
def set_x_lim(self, x_lim: tuple):
"""
Set the limits of the x-axis of the plot widget.
Args:
x_lim(tuple): Limits of the x-axis.
"""
self._image.set_x_lim(x_lim)
def set_y_lim(self, y_lim: tuple):
"""
Set the limits of the y-axis of the plot widget.
Args:
y_lim(tuple): Limits of the y-axis.
"""
self._image.set_y_lim(y_lim)
def set_grid(self, x_grid: bool, y_grid: bool):
"""
Set the grid visibility of the plot widget.
Args:
x_grid(bool): Visibility of the x-axis grid.
y_grid(bool): Visibility of the y-axis grid.
"""
self._image.set_grid(x_grid, y_grid)
def lock_aspect_ratio(self, lock: bool):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): Lock the aspect ratio.
"""
self._image.lock_aspect_ratio(lock)
###################################
# Toolbar Actions
###################################
@SafeSlot()
def toggle_auto_range(self):
"""
Set the auto range of the plot widget from the toolbar.
"""
self._image.set_auto_range(True, "xy")
@SafeSlot()
def toggle_fft(self):
checked = self.toolbar.widgets["FFT"].action.isChecked()
self.set_fft(checked)
@SafeSlot()
def toggle_log(self):
checked = self.toolbar.widgets["log"].action.isChecked()
self.set_log(checked)
@SafeSlot()
def toggle_transpose(self):
checked = self.toolbar.widgets["transpose"].action.isChecked()
self.set_transpose(checked)
@SafeSlot()
def rotate_left(self):
self.rotation = (self.rotation + 1) % 4
self.set_rotation(self.rotation)
@SafeSlot()
def rotate_right(self):
self.rotation = (self.rotation - 1) % 4
self.set_rotation(self.rotation)
@SafeSlot()
def reset_settings(self):
self.set_log(False)
self.set_fft(False)
self.set_transpose(False)
self.rotation = 0
self.set_rotation(0)
self.toolbar.widgets["FFT"].action.setChecked(False)
self.toolbar.widgets["log"].action.setChecked(False)
self.toolbar.widgets["transpose"].action.setChecked(False)
@SafeSlot()
def toggle_image_autorange(self):
"""
Enable the auto range of the image intensity.
"""
checked = self.toolbar.widgets["auto_range_image"].action.isChecked()
self._image.set_autorange(checked)
@SafeSlot()
def toggle_aspect_ratio(self):
"""
Enable the auto range of the image intensity.
"""
checked = self.toolbar.widgets["aspect_ratio"].action.isChecked()
self._image.lock_aspect_ratio(checked)
@SafeSlot()
def enable_mouse_rectangle_mode(self):
self.toolbar.widgets["rectangle_mode"].action.setChecked(True)
self.toolbar.widgets["drag_mode"].action.setChecked(False)
self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
@SafeSlot()
def enable_mouse_pan_mode(self):
self.toolbar.widgets["drag_mode"].action.setChecked(True)
self.toolbar.widgets["rectangle_mode"].action.setChecked(False)
self._image.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
@SafeSlot()
def enable_fps_monitor(self, enabled: bool):
"""
Enable the FPS monitor of the plot widget.
Args:
enabled(bool): If True, enable the FPS monitor.
"""
self._image.enable_fps_monitor(enabled)
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
def export(self):
"""
Show the export dialog for the plot widget.
"""
self._image.export()
def cleanup(self):
self.fig.cleanup()
self.toolbar.close()
self.toolbar.deleteLater()
return super().cleanup()
def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECImageWidget()
widget.image("waveform", "1d")
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -6,9 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots.waveform.waveform_plugin import WaveformPlugin
from bec_widgets.widgets.plots.image.bec_image_widget_plugin import BECImageWidgetPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(BECImageWidgetPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -1,59 +0,0 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import ToolbarBundle, WidgetAction
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
class NoCheckDelegate(QStyledItemDelegate):
"""To reduce space in combo boxes by removing the checkmark."""
def initStyleOption(self, option, index):
super().initStyleOption(option, index)
# Remove any check indicator
option.checkState = Qt.Unchecked
class MonitorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls monitor selection on a plot.
"""
def __init__(self, bundle_id="device_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# 1) Device combo box
self.device_combo_box = DeviceComboBox(
parent=self.target_widget,
device_filter=BECDeviceFilter.DEVICE,
readout_priority_filter=[ReadoutPriority.ASYNC],
)
self.device_combo_box.addItem("", None)
self.device_combo_box.setCurrentText("")
self.device_combo_box.setToolTip("Select Device")
self.device_combo_box.setItemDelegate(NoCheckDelegate(self.device_combo_box))
self.add_action("monitor", WidgetAction(widget=self.device_combo_box, adjust_size=True))
# 2) Dimension combo box
self.dim_combo_box = QComboBox()
self.dim_combo_box.addItems(["auto", "1d", "2d"])
self.dim_combo_box.setCurrentText("auto")
self.dim_combo_box.setToolTip("Monitor Dimension")
self.dim_combo_box.setFixedWidth(60)
self.dim_combo_box.setItemDelegate(NoCheckDelegate(self.dim_combo_box))
self.add_action("dim_combo", WidgetAction(widget=self.dim_combo_box, adjust_size=True))
# Connect slots, a device will be connected upon change of any combobox
self.device_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
self.dim_combo_box.currentTextChanged.connect(lambda: self.connect_monitor())
@SafeSlot()
def connect_monitor(self):
dim = self.dim_combo_box.currentText()
self.target_widget.image(monitor=self.device_combo_box.currentText(), monitor_type=dim)

View File

@@ -1,79 +0,0 @@
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.toolbar import MaterialIconAction, ToolbarBundle
class ImageProcessingToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that controls processing of monitor.
"""
def __init__(self, bundle_id="mouse_interaction", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
self.fft = MaterialIconAction(icon_name="fft", tooltip="Toggle FFT", checkable=True)
self.log = MaterialIconAction(icon_name="log_scale", tooltip="Toggle Log", checkable=True)
self.transpose = MaterialIconAction(
icon_name="transform", tooltip="Transpose Image", checkable=True
)
self.right = MaterialIconAction(
icon_name="rotate_right", tooltip="Rotate image clockwise by 90 deg"
)
self.left = MaterialIconAction(
icon_name="rotate_left", tooltip="Rotate image counterclockwise by 90 deg"
)
self.reset = MaterialIconAction(icon_name="reset_settings", tooltip="Reset Image Settings")
self.add_action("fft", self.fft)
self.add_action("log", self.log)
self.add_action("transpose", self.transpose)
self.add_action("rotate_right", self.right)
self.add_action("rotate_left", self.left)
self.add_action("reset", self.reset)
self.fft.action.triggered.connect(self.toggle_fft)
self.log.action.triggered.connect(self.toggle_log)
self.transpose.action.triggered.connect(self.toggle_transpose)
self.right.action.triggered.connect(self.rotate_right)
self.left.action.triggered.connect(self.rotate_left)
self.reset.action.triggered.connect(self.reset_settings)
@SafeSlot()
def toggle_fft(self):
checked = self.fft.action.isChecked()
self.target_widget.fft = checked
@SafeSlot()
def toggle_log(self):
checked = self.log.action.isChecked()
self.target_widget.log = checked
@SafeSlot()
def toggle_transpose(self):
checked = self.transpose.action.isChecked()
self.target_widget.transpose = checked
@SafeSlot()
def rotate_right(self):
if self.target_widget.rotation is None:
return
rotation = (self.target_widget.rotation - 1) % 4
self.target_widget.rotation = rotation
@SafeSlot()
def rotate_left(self):
if self.target_widget.rotation is None:
return
rotation = (self.target_widget.rotation + 1) % 4
self.target_widget.rotation = rotation
@SafeSlot()
def reset_settings(self):
self.target_widget.fft = False
self.target_widget.log = False
self.target_widget.transpose = False
self.target_widget.rotation = 0
self.fft.action.setChecked(False)
self.log.action.setChecked(False)
self.transpose.action.setChecked(False)

View File

@@ -0,0 +1 @@
{'files': ['motor_map_widget.py','motor_map_widget_plugin.py']}

View File

@@ -1,39 +1,41 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import os
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
import bec_widgets
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.motor_map.motor_map_widget import BECMotorMapWidget
DOM_XML = """
<ui language='c++'>
<widget class='MotorMap' name='motor_map'>
<widget class='BECMotorMapWidget' name='bec_motor_map_widget'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class MotorMapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class BECMotorMapWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = MotorMap(parent)
t = BECMotorMapWidget(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets"
return "BEC Plots"
def icon(self):
return designer_material_icon(MotorMap.ICON_NAME)
return designer_material_icon(BECMotorMapWidget.ICON_NAME)
def includeFile(self):
return "motor_map"
return "bec_motor_map_widget"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -45,10 +47,10 @@ class MotorMapPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "MotorMap"
return "BECMotorMapWidget"
def toolTip(self):
return "MotorMap"
return "BECMotorMapWidget"
def whatsThis(self):
return self.toolTip()

View File

@@ -1,824 +0,0 @@
from __future__ import annotations
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from bec_lib.endpoints import MessageEndpoints
from pydantic import BaseModel, Field, field_validator
from pydantic_core import PydanticCustomError
from qtpy import QtCore, QtGui
from qtpy.QtCore import Signal
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.utils import Colors, ConnectionConfig
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.motor_map.settings.motor_map_settings import MotorMapSettings
from bec_widgets.widgets.plots.motor_map.toolbar_bundles.motor_selection import (
MotorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots.plot_base import PlotBase
logger = bec_logger.logger
class FilledRectItem(pg.GraphicsObject):
"""
Custom rectangle item for the motor map plot defined by 4 points and a brush.
"""
def __init__(self, x: float, y: float, width: float, height: float, brush: QtGui.QBrush):
super().__init__()
self._rect = QtCore.QRectF(x, y, width, height)
self._brush = brush
self._pen = pg.mkPen(None)
def boundingRect(self):
return self._rect
def paint(self, painter, *args):
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True)
painter.setBrush(self._brush)
painter.setPen(self._pen)
painter.drawRect(self.boundingRect())
class MotorConfig(BaseModel):
name: str | None = Field(None, description="Motor name.")
limits: list[float] | None = Field(None, description="Motor limits.")
# noinspection PyDataclass
class MotorMapConfig(ConnectionConfig):
x_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor X name.")
y_motor: MotorConfig = Field(default_factory=MotorConfig, description="Motor Y name.")
color: str | tuple | None = Field(
(255, 255, 255, 255), description="The color of the last point of current position."
)
scatter_size: int | None = Field(5, description="Size of the scatter points.")
max_points: int | None = Field(5000, description="Maximum number of points to display.")
num_dim_points: int | None = Field(
100,
description="Number of points to dim before the color remains same for older recorded position.",
)
precision: int | None = Field(2, description="Decimal precision of the motor position.")
background_value: int | None = Field(
25, description="Background value of the motor map. Has to be between 0 and 255."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
@field_validator("background_value")
def validate_background_value(cls, value):
if not 0 <= value <= 255:
raise PydanticCustomError(
"wrong_value", f"'{value}' hs to be between 0 and 255.", {"wrong_value": value}
)
return value
class MotorMap(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "my_location"
USER_ACCESS = [
# General PlotBase Settings
"enable_toolbar",
"enable_toolbar.setter",
"enable_side_panel",
"enable_side_panel.setter",
"enable_fps_monitor",
"enable_fps_monitor.setter",
"set",
"title",
"title.setter",
"x_label",
"x_label.setter",
"y_label",
"y_label.setter",
"x_limits",
"x_limits.setter",
"y_limits",
"y_limits.setter",
"x_grid",
"x_grid.setter",
"y_grid",
"y_grid.setter",
"inner_axes",
"inner_axes.setter",
"outer_axes",
"outer_axes.setter",
"lock_aspect_ratio",
"lock_aspect_ratio.setter",
"auto_range_x",
"auto_range_x.setter",
"auto_range_y",
"auto_range_y.setter",
"x_log",
"x_log.setter",
"y_log",
"y_log.setter",
"legend_label_size",
"legend_label_size.setter",
# motor_map specific
"color",
"color.setter",
"max_points",
"max_points.setter",
"precision",
"precision.setter",
"num_dim_points",
"num_dim_points.setter",
"background_value",
"background_value.setter",
"scatter_size",
"scatter_size.setter",
"map",
"reset_history",
"get_data",
]
update_signal = Signal()
"""Motor map widget for plotting motor positions."""
def __init__(
self,
parent: QWidget | None = None,
config: MotorMapConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = True,
**kwargs,
):
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# Default values for PlotBase
self.x_grid = True
self.y_grid = True
# Gui specific
self._buffer = {"x": [], "y": []}
self._limit_map = None
self._trace = None
self.v_line = None
self.h_line = None
self.coord_label = None
self.motor_map_settings = None
# Connect slots
self.proxy_update_plot = pg.SignalProxy(
self.update_signal, rateLimit=25, slot=self._update_plot
)
self._add_motor_map_settings()
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_toolbar(self):
"""
Initialize the toolbar for the motor map widget.
"""
self.motor_selection_bundle = MotorSelectionToolbarBundle(
bundle_id="motor_selection", target_widget=self
)
self.toolbar.add_bundle(self.motor_selection_bundle, target_widget=self)
super()._init_toolbar()
self.toolbar.widgets["reset_legend"].action.setVisible(False)
self.reset_legend_action = MaterialIconAction(
icon_name="history", tooltip="Reset the position of legend."
)
self.toolbar.add_action_to_bundle(
bundle_id="roi",
action_id="motor_map_history",
action=self.reset_legend_action,
target_widget=self,
)
self.reset_legend_action.action.triggered.connect(self.reset_history)
def _add_motor_map_settings(self):
"""Add the motor map settings to the side panel."""
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=False)
self.side_panel.add_menu(
action_id="motor_map_settings",
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
widget=motor_map_settings,
title="Motor Map Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="settings_brightness",
tooltip="Show Motor Map Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="motor_map_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["motor_map_settings"].action.triggered.connect(
self.show_motor_map_settings
)
def show_motor_map_settings(self):
"""
Show the DAP summary popup.
"""
action = self.toolbar.widgets["motor_map_settings"].action
if self.motor_map_settings is None or not self.motor_map_settings.isVisible():
motor_map_settings = MotorMapSettings(parent=self, target_widget=self, popup=True)
self.motor_map_settings = SettingsDialog(
self,
settings_widget=motor_map_settings,
window_title="Motor Map Settings",
modal=False,
)
self.motor_map_settings.setFixedSize(250, 300)
# When the dialog is closed, update the toolbar icon and clear the reference
self.motor_map_settings.finished.connect(self._motor_map_settings_closed)
self.motor_map_settings.show()
action.setChecked(True)
else:
# If already open, bring it to the front
self.motor_map_settings.raise_()
self.motor_map_settings.activateWindow()
action.setChecked(True) # keep it toggled
def _motor_map_settings_closed(self):
"""
Slot for when the axis settings dialog is closed.
"""
self.motor_map_settings.deleteLater()
self.motor_map_settings = None
self.toolbar.widgets["motor_map_settings"].action.setChecked(False)
################################################################################
# Widget Specific Properties
################################################################################
# color_scatter for designer, color for CLI to not bother users with QColor
@SafeProperty("QColor")
def color_scatter(self) -> QtGui.QColor:
"""
Get the color of the motor trace.
Returns:
QColor: Color of the motor trace.
"""
return QColor(*self.config.color)
@color_scatter.setter
def color_scatter(self, color: str | tuple | QColor) -> None:
"""
Set color of the motor trace.
Args:
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
"""
if isinstance(color, str):
color = Colors.hex_to_rgba(color, 255)
if isinstance(color, QColor):
color = (color.red(), color.green(), color.blue(), color.alpha())
color = Colors.validate_color(color)
self.config.color = color
self.update_signal.emit()
self.property_changed.emit("color_scatter", color)
@property
def color(self) -> tuple:
"""
Get the color of the motor trace.
Returns:
tuple: Color of the motor trace.
"""
return self.config.color
@color.setter
def color(self, color: str | tuple) -> None:
"""
Set color of the motor trace.
Args:
color(str|tuple): Color of the motor trace. Can be HEX(str) or RGBA(tuple).
"""
self.color_scatter = color
@SafeProperty(int)
def max_points(self) -> int:
"""Get the maximum number of points to display."""
return self.config.max_points
@max_points.setter
def max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display.
Args:
max_points(int): Maximum number of points to display.
"""
self.config.max_points = max_points
self.update_signal.emit()
self.property_changed.emit("max_points", max_points)
@SafeProperty(int)
def precision(self) -> int:
"""
Set the decimal precision of the motor position.
"""
return self.config.precision
@precision.setter
def precision(self, precision: int) -> None:
"""
Set the decimal precision of the motor position.
Args:
precision(int): Decimal precision of the motor position.
"""
self.config.precision = precision
self.update_signal.emit()
self.property_changed.emit("precision", precision)
@SafeProperty(int)
def num_dim_points(self) -> int:
"""
Get the number of dim points for the motor map.
"""
return self.config.num_dim_points
@num_dim_points.setter
def num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of dim points for the motor map.
Args:
num_dim_points(int): Number of dim points.
"""
self.config.num_dim_points = num_dim_points
self.update_signal.emit()
self.property_changed.emit("num_dim_points", num_dim_points)
@SafeProperty(int)
def background_value(self) -> int:
"""
Get the background value of the motor map.
"""
return self.config.background_value
@background_value.setter
def background_value(self, background_value: int) -> None:
"""
Set the background value of the motor map.
Args:
background_value(int): Background value of the motor map.
"""
self.config.background_value = background_value
self._swap_limit_map()
self.property_changed.emit("background_value", background_value)
@SafeProperty(int)
def scatter_size(self) -> int:
"""
Get the scatter size of the motor map plot.
"""
return self.config.scatter_size
@scatter_size.setter
def scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map plot.
Args:
scatter_size(int): Size of the scatter points.
"""
self.config.scatter_size = scatter_size
self.update_signal.emit()
self.property_changed.emit("scatter_size", scatter_size)
################################################################################
# High Level methods for API
################################################################################
@SafeSlot()
def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None:
"""
Set the x and y motor names.
Args:
x_name(str): The name of the x motor.
y_name(str): The name of the y motor.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
"""
self.plot_item.clear()
if validate_bec:
self.entry_validator.validate_signal(x_name, None)
self.entry_validator.validate_signal(y_name, None)
self.config.x_motor.name = x_name
self.config.y_motor.name = y_name
motor_x_limit = self._get_motor_limit(self.config.x_motor.name)
motor_y_limit = self._get_motor_limit(self.config.y_motor.name)
self.config.x_motor.limits = motor_x_limit
self.config.y_motor.limits = motor_y_limit
# reconnect the signals
self._connect_motor_to_slots()
# Reset the buffer
self._buffer = {"x": [], "y": []}
# Redraw the motor map
self._make_motor_map()
self._sync_motor_map_selection_toolbar()
def reset_history(self):
"""
Reset the history of the motor map.
"""
self._buffer["x"] = [self._buffer["x"][-1]]
self._buffer["y"] = [self._buffer["y"][-1]]
self.update_signal.emit()
################################################################################
# BEC Update Methods
################################################################################
@SafeSlot()
def _update_plot(self, _=None):
"""Update the motor map plot."""
if self._trace is None:
return
# If the number of points exceeds max_points, delete the oldest points
if len(self._buffer["x"]) > self.config.max_points:
self._buffer["x"] = self._buffer["x"][-self.config.max_points :]
self._buffer["y"] = self._buffer["y"][-self.config.max_points :]
x = self._buffer["x"]
y = self._buffer["y"]
# Setup gradient brush for history
brushes = [pg.mkBrush(50, 50, 50, 255)] * len(x)
# RGB color
r, g, b, a = self.config.color
# Calculate the decrement step based on self.num_dim_points
num_dim_points = self.config.num_dim_points
decrement_step = (255 - 50) / num_dim_points
for i in range(1, min(num_dim_points + 1, len(x) + 1)):
brightness = max(60, 255 - decrement_step * (i - 1))
dim_r = int(r * (brightness / 255))
dim_g = int(g * (brightness / 255))
dim_b = int(b * (brightness / 255))
brushes[-i] = pg.mkBrush(dim_r, dim_g, dim_b, a)
brushes[-1] = pg.mkBrush(r, g, b, a) # Newest point is always full brightness
scatter_size = self.config.scatter_size
# Update the scatter plot
self._trace.setData(x=x, y=y, brush=brushes, pen=None, size=scatter_size)
# Get last know position for crosshair
current_x = x[-1]
current_y = y[-1]
# Update the crosshair
self._set_motor_indicator_position(current_x, current_y)
@SafeSlot(dict, dict)
def on_device_readback(self, msg: dict, metadata: dict) -> None:
"""
Update the motor map plot with the new motor position.
Args:
msg(dict): Message from the device readback.
metadata(dict): Metadata of the message.
"""
x_motor = self.config.x_motor.name
y_motor = self.config.y_motor.name
if x_motor is None or y_motor is None:
return
if x_motor in msg["signals"]:
x = msg["signals"][x_motor]["value"]
self._buffer["x"].append(x)
self._buffer["y"].append(self._buffer["y"][-1])
elif y_motor in msg["signals"]:
y = msg["signals"][y_motor]["value"]
self._buffer["y"].append(y)
self._buffer["x"].append(self._buffer["x"][-1])
self.update_signal.emit()
def _connect_motor_to_slots(self):
"""Connect motors to slots."""
self._disconnect_current_motors()
endpoints_readback = [
MessageEndpoints.device_readback(self.config.x_motor.name),
MessageEndpoints.device_readback(self.config.y_motor.name),
]
endpoints_limits = [
MessageEndpoints.device_limits(self.config.x_motor.name),
MessageEndpoints.device_limits(self.config.y_motor.name),
]
self.bec_dispatcher.connect_slot(self.on_device_readback, endpoints_readback)
self.bec_dispatcher.connect_slot(self.on_device_limits, endpoints_limits)
def _disconnect_current_motors(self):
"""Disconnect the current motors from the slots."""
if self.config.x_motor.name is not None and self.config.y_motor.name is not None:
endpoints_readback = [
MessageEndpoints.device_readback(self.config.x_motor.name),
MessageEndpoints.device_readback(self.config.y_motor.name),
]
endpoints_limits = [
MessageEndpoints.device_limits(self.config.x_motor.name),
MessageEndpoints.device_limits(self.config.y_motor.name),
]
self.bec_dispatcher.disconnect_slot(self.on_device_readback, endpoints_readback)
self.bec_dispatcher.disconnect_slot(self.on_device_limits, endpoints_limits)
################################################################################
# Utility Methods
################################################################################
@SafeSlot(dict, dict)
def on_device_limits(self, msg: dict, metadata: dict) -> None:
"""
Update the motor limits in the config.
Args:
msg(dict): Message from the device limits.
metadata(dict): Metadata of the message.
"""
self.config.x_motor.limits = self._get_motor_limit(self.config.x_motor.name)
self.config.y_motor.limits = self._get_motor_limit(self.config.y_motor.name)
self._swap_limit_map()
def _get_motor_limit(self, motor: str) -> list | None:
"""
Get the motor limit from the config.
Args:
motor(str): Motor name.
Returns:
float: Motor limit.
"""
try:
limits = self.dev[motor].limits
if limits == [0, 0]:
return None
return limits
except AttributeError: # TODO maybe not needed, if no limits it returns [0,0]
# If the motor doesn't have a 'limits' attribute, return a default value or raise a custom exception
logger.error(f"The device '{motor}' does not have defined limits.")
return None
def _make_motor_map(self) -> None:
"""
Make the motor map.
"""
motor_x_limit = self.config.x_motor.limits
motor_y_limit = self.config.y_motor.limits
self._limit_map = self._make_limit_map(motor_x_limit, motor_y_limit)
self.plot_item.addItem(self._limit_map)
self._limit_map.setZValue(-1)
# Create scatter plot
scatter_size = self.config.scatter_size
self._trace = pg.ScatterPlotItem(size=scatter_size, brush=pg.mkBrush(255, 255, 255, 255))
self.plot_item.addItem(self._trace)
self._trace.setZValue(0)
# Add the crosshair for initial motor coordinates
initial_position_x = self._get_motor_init_position(
self.config.x_motor.name, self.config.precision
)
initial_position_y = self._get_motor_init_position(
self.config.y_motor.name, self.config.precision
)
self._buffer["x"] = [initial_position_x]
self._buffer["y"] = [initial_position_y]
self._trace.setData([initial_position_x], [initial_position_y])
# Add initial crosshair
self._add_coordinates_crosshair(initial_position_x, initial_position_y)
# Set default labels for the plot
self.set_x_label_suffix(f"[{self.config.x_motor.name}-{self.config.x_motor.name}]")
self.set_y_label_suffix(f"[{self.config.y_motor.name}-{self.config.y_motor.name}]")
self.update_signal.emit()
def _add_coordinates_crosshair(self, x: float, y: float) -> None:
"""
Add position crosshair indicator to the plot.
Args:
x(float): X coordinate of the crosshair.
y(float): Y coordinate of the crosshair.
"""
if self.v_line is not None and self.h_line is not None and self.coord_label is not None:
self.plot_item.removeItem(self.h_line)
self.plot_item.removeItem(self.v_line)
self.plot_item.removeItem(self.coord_label)
self.h_line = pg.InfiniteLine(
angle=0, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
self.v_line = pg.InfiniteLine(
angle=90, movable=False, pen=pg.mkPen(color="r", width=1, style=QtCore.Qt.DashLine)
)
self.coord_label = pg.TextItem("", anchor=(1, 1), fill=(0, 0, 0, 100))
# Add crosshair to the plot
self.plot_item.addItem(self.h_line)
self.plot_item.addItem(self.v_line)
self.plot_item.addItem(self.coord_label)
self._set_motor_indicator_position(x, y)
def _set_motor_indicator_position(self, x: float, y: float) -> None:
"""
Set the position of the motor indicator.
Args:
x(float): X coordinate of the motor indicator.
y(float): Y coordinate of the motor indicator.
"""
if self.v_line is None or self.h_line is None or self.coord_label is None:
return
text = f"({x:.{self.config.precision}f}, {y:.{self.config.precision}f})"
self.v_line.setPos(x)
self.h_line.setPos(y)
self.coord_label.setText(text)
self.coord_label.setPos(x, y)
def _make_limit_map(self, limits_x: list | None, limits_y: list | None) -> FilledRectItem:
"""
Create a limit map for the motor map plot. Each limit can be:
- [int, int]
- [None, None]
- [int, None]
- [None, int]
- or None
If any element of a limit list is None, it is treated as unbounded,
and replaced with ±1e6 (or any large float of your choice).
Args:
limits_x(list): Motor limits for the x-axis.
limits_y(list): Motor limits for the y-axis.
Returns:
FilledRectItem: Limit map.
"""
def fix_limit_pair(limits):
if not limits:
return [-1e6, 1e6]
low, high = limits
if low is None:
low = -1e6
if high is None:
high = 1e6
return [low, high]
limits_x = fix_limit_pair(limits_x)
limits_y = fix_limit_pair(limits_y)
limit_x_min, limit_x_max = limits_x
limit_y_min, limit_y_max = limits_y
rect_width = limit_x_max - limit_x_min
rect_height = limit_y_max - limit_y_min
background_value = self.config.background_value
brush_color = pg.mkBrush(background_value, background_value, background_value, 150)
filled_rect = FilledRectItem(
x=limit_x_min, y=limit_y_min, width=rect_width, height=rect_height, brush=brush_color
)
return filled_rect
def _swap_limit_map(self):
"""Swap the limit map."""
self.plot_item.removeItem(self._limit_map)
x_limits = self.config.x_motor.limits
y_limits = self.config.y_motor.limits
if x_limits is not None and y_limits is not None:
self._limit_map = self._make_limit_map(x_limits, y_limits)
self._limit_map.setZValue(-1)
self.plot_item.addItem(self._limit_map)
def _get_motor_init_position(self, name: str, precision: int) -> float:
"""
Get the motor initial position from the config.
Args:
name(str): Motor name.
precision(int): Decimal precision of the motor position.
Returns:
float: Motor initial position.
"""
entry = self.entry_validator.validate_signal(name, None)
init_position = round(float(self.dev[name].read()[entry]["value"]), precision)
return init_position
def _sync_motor_map_selection_toolbar(self):
"""
Sync the motor map selection toolbar with the current motor map.
"""
if self.motor_selection_bundle is not None:
motor_x = self.motor_selection_bundle.motor_x.currentText()
motor_y = self.motor_selection_bundle.motor_y.currentText()
if motor_x != self.config.x_motor.name:
self.motor_selection_bundle.motor_x.blockSignals(True)
self.motor_selection_bundle.motor_x.set_device(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.check_validity(self.config.x_motor.name)
self.motor_selection_bundle.motor_x.blockSignals(False)
if motor_y != self.config.y_motor.name:
self.motor_selection_bundle.motor_y.blockSignals(True)
self.motor_selection_bundle.motor_y.set_device(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.check_validity(self.config.y_motor.name)
self.motor_selection_bundle.motor_y.blockSignals(False)
################################################################################
# Export Methods
################################################################################
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
data = {"x": self._buffer["x"], "y": self._buffer["y"]}
return data
class DemoApp(QMainWindow): # pragma: no cover
def __init__(self):
super().__init__()
self.setWindowTitle("Waveform Demo")
self.resize(800, 600)
self.main_widget = QWidget()
self.layout = QHBoxLayout(self.main_widget)
self.setCentralWidget(self.main_widget)
self.motor_map_popup = MotorMap(popups=True)
self.motor_map_popup.map(x_name="samx", y_name="samy", validate_bec=True)
self.motor_map_side = MotorMap(popups=False)
self.motor_map_side.map(x_name="samx", y_name="samy", validate_bec=True)
self.layout.addWidget(self.motor_map_side)
self.layout.addWidget(self.motor_map_popup)
if __name__ == "__main__": # pragma: no cover
import sys
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
set_theme("dark")
widget = DemoApp()
widget.show()
widget.resize(1400, 600)
sys.exit(app.exec_())

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