1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-04-19 23:05:36 +02:00

Compare commits

...

37 Commits

Author SHA1 Message Date
1b680b0b1d WIP very messy ui as a server window, however, works as expected 2025-03-19 16:50:08 +01:00
d27edbbf6d WIP server has BEC qapp imported 2025-03-19 15:45:02 +01:00
1e87d1021c WIP main window is creating docks with status bar and actions bar 2025-03-19 15:44:39 +01:00
f975f372d6 WIP client can connect to different gui_id 2025-03-19 15:44:14 +01:00
ff27b0e569 WIP qmainwindow weblinks added 2025-03-19 15:41:48 +01:00
3c527b9a76 WIP qapp and qmain window adjusted init 2025-03-19 14:49:01 +01:00
35b9ede67f WIP qapp and bec widget POC adjustments 2025-03-19 14:40:41 +01:00
5c38ea76eb WIP qapp POC 2025-03-18 15:44:19 +01:00
f6e2b49204 refactor(motor_map_widget): BECMotorMapWidget removed 2025-03-18 15:42:58 +01:00
98b6790223 feat(motor_map): new MotorMap widget based on PlotBase 2025-03-18 15:42:58 +01:00
3606053bf0 fix(plot_base): do not enable inner axes when label is changed 2025-03-18 15:02:41 +01:00
0336804fba fix(plot_base): axis setting filter for relevant properties 2025-03-17 13:51:31 +01:00
0753e359c6 fix(scatter_waveform,waveform): Added QTimer to fetch the last data points after 500ms 2025-03-14 13:29:27 +01:00
69bc40a8e4 feat(scatter_waveform): scatter waveform widget based on new Plotbase 2025-03-14 10:53:44 +01:00
75dbbf7663 refactor(tests): create dummy scan item moved to client_mocks.py 2025-03-14 10:53:44 +01:00
a5be450590 fix(plot_base): improved handling of matplotlib exporter errors 2025-03-14 10:53:44 +01:00
7127bba1de fix(plot_base): ability to set y label suffix 2025-03-14 10:53:44 +01:00
6b265b1d30 refactor(image_widget): old BECImageWidget removed 2025-03-14 10:51:34 +01:00
0a9d2c4d18 feat(image): new Image widget based on new PlotBase 2025-03-14 10:51:34 +01:00
37f736954f fix(toolbar): update action check handling logic for SwitchableToolBarAction 2025-03-13 22:42:02 +01:00
bea107b036 fix(plot_base): enable popup property fixed 2025-03-13 22:42:02 +01:00
532f2957b4 fix(crosshair): adapted for 2D image 2025-03-13 22:42:02 +01:00
7a676d6078 test: disable test_bec_dock_rpc_e2e module, issue to fix this created #450 2025-03-13 17:50:41 +01:00
cc3cb8ccb1 tests: fix e2e tests for namespace refactoring 2025-03-13 17:28:39 +01:00
81c306611d refactor: cleanup MR 2025-03-13 17:28:39 +01:00
c5b384c7f5 feat!: namespace update for gui, dock_area and docks. 2025-03-13 17:25:35 +01:00
a186e66b92 docs(plot_base): update docstrings for properties and setters 2025-03-13 17:20:43 +01:00
183104456f refactor(waveform_widget): removed and replaced by Waveform 2025-03-13 17:20:43 +01:00
d1a6a59b86 test(plot_indicators): tests adapted to not be dependent on BECWaveformWidget 2025-03-13 17:20:43 +01:00
1562000b37 fix(plot_indicators): cleanup adjusted 2025-03-13 17:20:43 +01:00
2b37ac154e feat(waveform): new Waveform widget based on NextGen PlotBase 2025-03-13 17:20:43 +01:00
59bc5996df fix(entry_validator): validator reports list of signal if user chooses the wrong one 2025-03-13 17:20:43 +01:00
06d7f239ed fix(plot_base): update mouse mode state on mode change 2025-03-13 17:20:43 +01:00
5920b26c02 fix(plot_base): aspect ratio removed from the PlotBase 2025-03-13 17:20:43 +01:00
9439e4a275 fix(plot_base): inner and outer axis setting in popup mode 2025-03-13 17:20:43 +01:00
9342bffe85 fix(plot_base): fix cleanup of popups if popups are still open when PlotBase is closed 2025-03-13 17:20:43 +01:00
47ee8e5599 fix(lmfit_dialog_vertical): vertical sizePolicy fixed 2025-03-13 17:20:43 +01:00
111 changed files with 13403 additions and 5288 deletions

View File

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

@@ -35,9 +35,9 @@ class AutoUpdates:
Create a default dock for the auto updates.
"""
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]
self._default_dock = self.gui.new(self.dock_name)
self._default_dock.new("BECFigure")
self._default_fig = self._default_dock.elements_list[0]
@staticmethod
def get_scan_info(msg) -> ScanInfo:

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
"""Client utilities for the BEC GUI."""
from __future__ import annotations
import importlib
@@ -8,12 +10,12 @@ import select
import subprocess
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.utils.import_utils import isinstance_based_on_class_name, lazy_import, lazy_import_from
from bec_lib.utils.import_utils import lazy_import, lazy_import_from
from rich.console import Console
from rich.table import Table
import bec_widgets.cli.client as client
from bec_widgets.cli.auto_updates import AutoUpdates
@@ -23,16 +25,16 @@ 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
from bec_lib.redis_connector import StreamMessage
else:
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",))
StreamMessage = lazy_import_from("bec_lib.redis_connector", ("StreamMessage",))
logger = bec_logger.logger
IGNORE_WIDGETS = ["BECDockArea", "BECDock"]
def _filter_output(output: str) -> str:
"""
@@ -67,7 +69,9 @@ def _get_output(process, logger) -> None:
logger.error(f"Error reading process output: {str(e)}")
def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger=None) -> None:
def _start_plot_process(
gui_id: str, gui_class: type, gui_class_id: str, config: dict | str, logger=None
) -> tuple[subprocess.Popen[str], threading.Thread | None]:
"""
Start the plot in a new process.
@@ -76,7 +80,16 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
process will not be captured.
"""
# pylint: disable=subprocess-run-check
command = ["bec-gui-server", "--id", gui_id, "--gui_class", gui_class.__name__, "--hide"]
command = [
"bec-gui-server",
"--id",
gui_id,
"--gui_class",
gui_class.__name__,
"--gui_class_id",
gui_class_id,
"--hide",
]
if config:
if isinstance(config, dict):
config = json.dumps(config)
@@ -111,16 +124,20 @@ def _start_plot_process(gui_id: str, gui_class: type, config: dict | str, logger
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):
def wait_for_server(client: BECGuiClient):
"""Context manager to wait for the server to start."""
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:
@@ -138,57 +155,122 @@ def wait_for_server(client):
yield
### ----------------------------
### 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 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 f""
class AvailableWidgetsNamespace:
"""Namespace for available widgets in the BEC GUI."""
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 "" # f"<{self.__class__.__name__}>"
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
"""Extend the BECDockArea class and add namespaces to access widgets of docks."""
client.BECDockArea = BECDockArea
### ----------------------------
@dataclass
class WidgetDesc:
title: str
widget: BECDockArea
def __init__(self, gui_id=None, config=None, name=None, parent=None):
super().__init__(gui_id, config, name, parent)
# Add namespaces for DockArea
self.elements = WidgetNameSpace()
class BECGuiClient(RPCBase):
_top_level = {}
"""BEC GUI client class. Container for GUI applications within Python."""
_top_level: dict[str, BECDockArea] = {}
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._default_dock_name = "bec"
self._auto_updates_enabled = True
self._auto_updates = None
self._killed = False
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._connected_gui_id = None
def connect_gui(self, gui_id: str, timeout: int = 3):
"""Connect to a running GUI by gui_id."""
self._connected_gui_id = gui_id
self._gui_id = gui_id
if not self._gui_is_alive():
self._connected_gui_id = None
raise RuntimeError(f"No running GUI found with gui_id '{gui_id}'.")
logger.success(f"Connected successfully to GUI '{gui_id}'.")
def disconnect_gui(self):
"""Disconnect from currently connected GUI."""
if self._connected_gui_id:
logger.info(f"Disconnected from GUI '{self._connected_gui_id}'.")
self._connected_gui_id = None
self._gui_id = None
else:
logger.warning("No GUI is currently connected.")
def switch_gui(self, new_gui_id: str):
"""Convenience method to quickly switch between GUI instances."""
self.disconnect_gui()
self.connect_gui(new_gui_id)
def run_rpc_on_gui(self, action, args=None, kwargs=None, timeout=3):
"""Run an RPC action on the connected GUI."""
if not self._connected_gui_id:
raise RuntimeError("No GUI connected. Call 'connect_gui(gui_id)' first.")
return self._run_rpc(action, *(args or []), timeout=timeout, **(kwargs or {}))
# @property
# def all_connections(self):
# """Get all connections to the GUI."""
# all_connections = self.
# # pylint: disable=protected-access
# return client.RPCRegister()._rpc_register
@property
def windows(self):
def windows(self) -> dict:
"""Dictionary with dock areas in the GUI."""
return self._top_level
@property
def auto_updates(self):
if self._auto_updates_enabled:
with wait_for_server(self):
return self._auto_updates
def window_list(self) -> list:
"""List with dock areas in the GUI."""
return list(self._top_level.values())
# FIXME AUTO UPDATES
# @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")
@@ -199,71 +281,73 @@ class BECGuiClient(RPCBase):
# if the module is not found, we skip it
if spec is None:
continue
return ep.load()(gui=self._top_level["main"].widget)
return ep.load()(gui=self._top_level["main"])
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
# FIXME AUTO UPDATES
# @property
# def selected_device(self) -> str | None:
# """
# 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")
# @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)
# FIXME AUTO UPDATES
# 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 _handle_msg_update(self, msg: StreamMessage) -> 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 _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)
# 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._top_level[self._default_dock_name] = BECDockArea(
gui_id=f"{self._default_dock_name}", name=self._default_dock_name, parent=self
)
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()
def start_server(self, wait=False) -> None:
def _start_server(self, wait: bool = False) -> None:
"""
Start the GUI server, and execute callback when it is launched
"""
@@ -272,7 +356,11 @@ class BECGuiClient(RPCBase):
self._startup_timeout = 5
self._gui_started_event.clear()
self._process, self._process_output_processing_thread = _start_plot_process(
self._gui_id, self.__class__, self._client._service_config.config, logger=logger
self._gui_id,
self.__class__,
gui_class_id=self._default_dock_name,
config=self._client._service_config.config, # pylint: disable=protected-access
logger=logger,
)
def gui_started_callback(callback):
@@ -283,7 +371,7 @@ class BECGuiClient(RPCBase):
threading.current_thread().cancel()
self._gui_started_timer = RepeatTimer(
0.5, lambda: self.gui_is_alive() and gui_started_callback(self._gui_post_startup)
0.5, lambda: self._gui_is_alive() and gui_started_callback(self._gui_post_startup)
)
self._gui_started_timer.start()
@@ -294,54 +382,104 @@ class BECGuiClient(RPCBase):
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 start(self, wait: bool = True) -> None:
"""Start the server and show the GUI window."""
return self._start_server(wait=wait)
def _do_show_all(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
rpc_client._run_rpc("show")
rpc_client._run_rpc("show") # pylint: disable=protected-access
for window in self._top_level.values():
window.widget.show()
window.show()
def show_all(self):
def _show_all(self):
with wait_for_server(self):
return self._do_show_all()
def hide_all(self):
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()
rpc_client._run_rpc("hide") # pylint: disable=protected-access
# because of the registry callbacks, we may have
# dock areas that are already killed, but not yet
# removed from the registry state
if not self._killed:
for window in self._top_level.values():
window.hide()
def show(self):
"""Show the GUI window."""
if self._process is not None:
return self.show_all()
return self._show_all()
# backward compatibility: show() was also starting server
return self.start_server(wait=True)
return self._start_server(wait=True)
def hide(self):
return self.hide_all()
"""Hide the GUI window."""
return self._hide_all()
@property
def main(self):
"""Return client to main dock area (in main window)"""
with wait_for_server(self):
return self._top_level["main"].widget
def new(
self,
name: str | None = None,
wait: bool = True,
geometry: tuple[int, int, int, int] | None = None,
) -> BECDockArea:
"""Create a new top-level 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:
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:
BECDockArea: The new dock area.
"""
Close the gui window.
if len(self.window_list) == 0:
self.show()
if wait:
with wait_for_server(self):
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
self._top_level[widget.widget_name] = widget
return widget
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
widget = rpc_client._run_rpc(
"new_dock_area", name, geometry
) # pylint: disable=protected-access
self._top_level[widget.widget_name] = widget
return widget
def list_all_rpc(self) -> dict:
"""List all RPC connections."""
rpc_client = RPCBase(gui_id=f"{self._gui_id}:window", parent=self)
list = rpc_client._run_rpc("list_all_rpc") # pylint: disable=protected-access
def delete(self, name: str) -> None:
"""Delete a dock area.
Args:
name(str): The name of the dock area.
"""
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.keys():
self.delete(widget_name)
def close(self):
"""Deprecated. Use kill_server() instead."""
# FIXME, deprecated in favor of kill, will be removed in the future
self.kill_server()
def kill_server(self) -> None:
"""Kill the GUI server."""
self._top_level.clear()
self._killed = True
if self._gui_started_timer is not None:
self._gui_started_timer.cancel()
@@ -357,3 +495,17 @@ class BECGuiClient(RPCBase):
self._process_output_processing_thread.join()
self._process.wait()
self._process = None
if __name__ == "__main__": # pragma: no cover
from bec_lib.client import BECClient
from bec_lib.service_config import ServiceConfig
config = ServiceConfig()
client = BECClient(config)
client.start()
# Test the client_utils.py module
gui = BECGuiClient()
gui.start()
print(gui.window_list)

View File

@@ -95,9 +95,21 @@ 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

@@ -3,7 +3,7 @@ from __future__ import annotations
import threading
import uuid
from functools import wraps
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, cast
from bec_lib.client import BECClient
from bec_lib.endpoints import MessageEndpoints
@@ -44,7 +44,7 @@ def rpc_call(func):
for key, val in kwargs.items():
if hasattr(val, "name"):
kwargs[key] = val.name
if not self.gui_is_alive():
if not self._root._gui_is_alive():
raise RuntimeError("GUI is not alive")
return self._run_rpc(func.__name__, *args, **kwargs)
@@ -61,10 +61,17 @@ class RPCResponseTimeoutError(Exception):
class RPCBase:
def __init__(self, gui_id: str = None, config: dict = None, parent=None) -> None:
def __init__(
self,
gui_id: str | None = None,
config: dict | None = None,
name: str | None = None,
parent=None,
) -> None:
self._client = BECClient() # BECClient is a singleton; here, we simply get the instance
self._config = config if config is not None else {}
self._gui_id = gui_id if gui_id is not None else str(uuid.uuid4())[:5]
self._name = name if name is not None else str(uuid.uuid4())[:5]
self._parent = parent
self._msg_wait_event = threading.Event()
self._rpc_response = None
@@ -74,7 +81,20 @@ class RPCBase:
def __repr__(self):
type_ = type(self)
qualname = type_.__qualname__
return f"<{qualname} object at {hex(id(self))}>"
return f"<{qualname} with name: {self.widget_name}>"
def remove(self):
"""
Remove the widget.
"""
self._run_rpc("remove")
@property
def widget_name(self):
"""
Get the widget name.
"""
return self._name
@property
def _root(self):
@@ -88,7 +108,7 @@ class RPCBase:
parent = parent._parent
return parent
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs):
def _run_rpc(self, method, *args, wait_for_rpc_response=True, timeout=3, **kwargs) -> Any:
"""
Run the RPC call.
@@ -165,7 +185,7 @@ class RPCBase:
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.
"""

View File

@@ -1,10 +1,21 @@
from __future__ import annotations
from functools import wraps
from threading import Lock
from typing import TYPE_CHECKING, Callable
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
class RPCRegister:
"""
@@ -49,7 +60,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:
def get_rpc_by_id(self, gui_id: str) -> QObject | None:
"""
Get an RPC object by its ID.
@@ -57,7 +68,7 @@ class RPCRegister:
gui_id(str): The ID of the RPC object to be retrieved.
Returns:
QObject: The RPC object with the given ID.
QObject | None: The RPC object with the given ID or None
"""
rpc_object = self._rpc_register.get(gui_id, None)
return rpc_object
@@ -73,6 +84,19 @@ class RPCRegister:
connections = dict(self._rpc_register)
return connections
def get_names_of_rpc_by_class_type(
self, cls: BECWidget | BECConnector | BECDock | 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._name for widget in widgets]
@classmethod
def reset_singleton(cls):
"""

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
from bec_widgets.utils import BECConnector
from typing import Any
from bec_widgets.cli.client_utils import IGNORE_WIDGETS
from bec_widgets.utils.bec_widget import BECWidget
class RPCWidgetHandler:
@@ -10,7 +13,7 @@ class RPCWidgetHandler:
self._widget_classes = None
@property
def widget_classes(self):
def widget_classes(self) -> dict[str, type[BECWidget]]:
"""
Get the available widget classes.
@@ -19,7 +22,7 @@ class RPCWidgetHandler:
"""
if self._widget_classes is None:
self.update_available_widgets()
return self._widget_classes
return self._widget_classes # type: ignore
def update_available_widgets(self):
"""
@@ -31,24 +34,25 @@ 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}
self._widget_classes = {
cls.__name__: cls for cls in clss.widgets if cls.__name__ not in IGNORE_WIDGETS
}
def create_widget(self, widget_type, **kwargs) -> BECConnector:
def create_widget(self, widget_type, name: str | None = None, **kwargs) -> BECWidget:
"""
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(BECConnector): The created widget.
widget(BECWidget): The created widget.
"""
if self._widget_classes is None:
self.update_available_widgets()
widget_class = self._widget_classes.get(widget_type)
widget_class = self.widget_classes.get(widget_type) # type: ignore
if widget_class:
return widget_class(**kwargs)
return widget_class(name=name, **kwargs)
raise ValueError(f"Unknown widget type: {widget_type}")

View File

@@ -12,16 +12,18 @@ from bec_lib.endpoints import MessageEndpoints
from bec_lib.logger import bec_logger
from bec_lib.service_config import ServiceConfig
from bec_lib.utils.import_utils import lazy_import
from bec_widgets.examples.qapp_custom.bec_qapp import BECQApplication
from qtpy.QtCore import Qt, QTimer
from redis.exceptions import RedisError
from bec_widgets.cli.rpc import rpc_register
from bec_widgets.cli.rpc.rpc_register import RPCRegister
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
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, WindowWithUi
messages = lazy_import("bec_lib.messages")
logger = bec_logger.logger
@@ -36,6 +38,8 @@ def rpc_exception_hook(err_func):
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)})
@@ -56,16 +60,18 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
gui_class: Union[BECFigure, BECDockArea, WindowWithUi] = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.gui = gui_class(gui_id=self.gui_id)
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
self.gui = gui_class(parent=None, name=gui_class_id, gui_id=gui_class_id)
# self.rpc_register.add_rpc(self.gui)
self.dispatcher.connect_slot(
self.on_rpc_update, MessageEndpoints.gui_instructions(self.gui_id)
@@ -78,6 +84,7 @@ class BECWidgetsCLIServer:
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -135,6 +142,9 @@ class BECWidgetsCLIServer:
if isinstance(obj, BECConnector):
return {
"gui_id": obj.gui_id,
"name": (
obj._name if hasattr(obj, "_name") else obj.__class__.__name__
), # pylint: disable=protected-access
"widget_class": obj.__class__.__name__,
"config": obj.config.model_dump(),
"__rpc__": True,
@@ -179,7 +189,12 @@ class SimpleFileLikeFromLogOutputFunc:
return
def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config: str | None = None):
def _start_server(
gui_id: str,
gui_class: Union[BECFigure, BECDockArea, BECMainWindow],
gui_class_id: str = "bec",
config: str | None = None,
):
if config:
try:
config = json.loads(config)
@@ -196,7 +211,9 @@ def _start_server(gui_id: str, gui_class: Union[BECFigure, BECDockArea], config:
# service_name="BECWidgetsCLIServer",
# service_config=service_config.service_config,
# )
server = BECWidgetsCLIServer(gui_id=gui_id, config=service_config, gui_class=gui_class)
server = BECWidgetsCLIServer(
gui_id=gui_id, config=service_config, gui_class=gui_class, gui_class_id=gui_class_id
)
return server
@@ -217,6 +234,12 @@ 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")
@@ -232,16 +255,18 @@ def main():
gui_class = BECDockArea
elif args.gui_class == "BECFigure":
gui_class = BECFigure
elif args.gui_class == "MainWindow":
gui_class = WindowWithUi
else:
print(
"Please specify a valid gui_class to run. Use -h for help."
"\n Starting with default gui_class BECFigure."
)
gui_class = BECDockArea
gui_class = WindowWithUi
with redirect_stdout(SimpleFileLikeFromLogOutputFunc(logger.info)):
with redirect_stderr(SimpleFileLikeFromLogOutputFunc(logger.error)):
app = QApplication(sys.argv)
app = BECQApplication(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
@@ -256,14 +281,14 @@ def main():
# 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)
# args.id = "abff6"
server = _start_server(args.id, gui_class, args.gui_class_id, args.config)
win = BECMainWindow(gui_id=f"{server.gui_id}:window")
win.setAttribute(Qt.WA_ShowWithoutActivating)
win.setWindowTitle("BEC Widgets")
win.setWindowTitle("BEC")
RPCRegister().add_rpc(win)
gui = server.gui
win.setCentralWidget(gui)
if not args.hide:

View File

@@ -3,6 +3,8 @@ import os
import numpy as np
import pyqtgraph as pg
from bec_qthemes import material_icon
from bec_widgets.examples.qapp_custom.bec_qapp import BECQApplication
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from qtpy.QtWidgets import (
QApplication,
QGroupBox,
@@ -15,12 +17,16 @@ from qtpy.QtWidgets import (
)
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
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_next_gen.image.image import Image
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -37,6 +43,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
{
"np": np,
"pg": pg,
"wh": wh,
"fig": self.figure,
"dock": self.dock,
"w1": self.w1,
@@ -49,13 +56,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w8": self.w8,
"w9": self.w9,
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
"im": self.im,
"mi": self.mi,
"mm": self.mm,
"mw": self.mw,
"lm": self.lm,
"btn1": self.btn1,
"btn2": self.btn2,
@@ -65,6 +68,9 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
}
)
@@ -100,7 +106,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.pb = PlotBase()
self.pi = self.pb.plot_item
fourth_tab_layout.addWidget(self.pb)
tab_widget.addTab(fourth_tab, "PltoBase")
tab_widget.addTab(fourth_tab, "PlotBase")
tab_widget.setCurrentIndex(3)
@@ -117,14 +123,52 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
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)
sixth_tab = QWidget()
sixth_tab_layout = QVBoxLayout(sixth_tab)
self.im = Image()
self.mi = self.im.main_image
sixth_tab_layout.addWidget(self.im)
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(5)
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)
# 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.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve1")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve2")
# self.wfng._add_curve_custom(x=np.arange(10), y=np.random.rand(10), label="curve3")
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(
@@ -180,23 +224,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
col=2,
)
def _init_dock(self):
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.cleanup()
@@ -215,20 +242,21 @@ if __name__ == "__main__": # pragma: no cover
module_path = os.path.dirname(bec_widgets.__file__)
app = QApplication(sys.argv)
app = BECQApplication(sys.argv)
app.setApplicationName("Jupyter Console")
app.setApplicationDisplayName("Jupyter Console")
apply_theme("dark")
icon = material_icon("terminal", color="#434343", filled=True)
icon = material_icon("terminal", color=(255, 255, 255, 255), filled=True)
app.setWindowIcon(icon)
bec_dispatcher = BECDispatcher()
client = bec_dispatcher.client
client.start()
win = JupyterConsoleWindow()
win = BECMainWindow()
widget = JupyterConsoleWindow()
win.setCentralWidget(widget)
win.show()
win.resize(1200, 800)
win.resize(1500, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())

View File

@@ -0,0 +1,101 @@
import os
from bec_widgets.utils.bec_widget import BECWidget
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 import BECDispatcher
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECQApplication(QApplication):
def __init__(self, client=None, gui_id: str | None = None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = None
self.rpc_register = None
self.dispatcher = None
self.is_bec_app = None
self.bec_props = None
self.gui_id = None
self.setup_bec_features()
def setup_bec_features(self):
self.bec_props = {}
self.is_bec_app = True
self.dispatcher = BECDispatcher()
self.rpc_register = RPCRegister()
self.client = self.dispatcher.client
self.gui_id = "1234"
self.rpc_register.add_rpc(self)
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.setWindowIcon(icon)
# self.setup_icon()
print("[BECQApplication]: Features initialized with BECDispatcher singleton.")
def inject_property(self, name, value):
self.bec_props[name] = value
print(f"[BECQApplication]: Injected property '{name}' = {value}")
def show_gui_id(self):
print(f"[BECQApplication]: GUI ID: {self.gui_id}")
def setup_icon(self):
icon = QIcon()
icon.addFile(
os.path.join(MODULE_PATH, "assets", "app_icons", "bec_widgets_icon.png"),
size=QSize(48, 48),
)
self.setWindowIcon(icon)
print("[BECQApplication]: Window icon set.")
def register_all(self):
widgets = self.allWidgets()
all_connections = self.rpc_register.list_all_connections()
for widget in widgets:
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}"
)
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 shutdown(self):
self.dispatcher.disconnect_all()
super().shutdown()
def upgrade_to_becqapp():
app = QApplication.instance()
if app is None:
raise RuntimeError("No QApplication instance found!")
if getattr(app, "is_bec_app", False):
print("[BECQApplication]: Already upgraded.")
return app
# Only inject your explicitly defined Python methods
methods_to_inject = ["setup_bec_features", "inject_property"]
for method_name in methods_to_inject:
method = getattr(BECQApplication, method_name)
setattr(app, method_name, method.__get__(app, QApplication))
app.setup_bec_features()
print("[BECQApplication]: QApplication upgraded to BECQApplication.")
return app

View File

@@ -0,0 +1,25 @@
from bec_qapp import upgrade_to_becqapp
from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget
class BECWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# Upgrade qApp if necessary
app = QApplication.instance()
if not getattr(app, "is_bec_app", False):
print("[BECWidget]: Upgrading QApplication instance to BECQApplication.")
app = upgrade_to_becqapp()
else:
print("[BECWidget]: BECQApplication already active.")
app.inject_property("widget_initialized", True)
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout()
label = QLabel("BECWidget is running with BECQApplication features.")
layout.addWidget(label)
self.setLayout(layout)

View File

@@ -0,0 +1,46 @@
import sys
from PySide6.QtWidgets import QApplication
from bec_widget import BECWidget
from qtpy.QtWidgets import QMainWindow, QVBoxLayout, QWidget
from bec_widgets.examples.qapp_custom.bec_qapp import BECQApplication
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
class DemoApp(BECMainWindow):
def __init__(self):
super().__init__()
# self.setWindowTitle("Demo Application")
# self.setGeometry(100, 100, 600, 400)
# Create an instance of BECWidget
self.main_widget = QWidget(self)
self.setCentralWidget(self.main_widget)
self.main_widget.layout = QVBoxLayout(self.main_widget)
self.bec_widget_1 = BECWidget()
self.bec_widget_2 = BECWidget()
# Set up the UI for the BECWidget
self.bec_widget_1.setup_ui()
self.bec_widget_2.setup_ui()
self.main_widget.layout.addWidget(self.bec_widget_1)
self.main_widget.layout.addWidget(self.bec_widget_2)
self.setWindowIcon(self.windowIcon())
def main():
app = QApplication(sys.argv)
# app = BECQApplication(sys.argv)
widget = DemoApp()
widget.resize(400, 200)
widget.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

View File

@@ -317,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_widget import BECWaveformWidget
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
self.plot = BECWaveformWidget()
self.plot = Waveform()
self.layout.addWidget(self.plot)
self.add_side_menus()

View File

@@ -279,7 +279,6 @@ 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)
@@ -287,23 +286,54 @@ 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.menu_actions.items():
menu_act.setChecked(k == key)
for k, menu_act in self.actions.items():
menu_act.action.setChecked(False)
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()
@@ -318,11 +348,18 @@ class WidgetAction(ToolBarAction):
widget (QWidget): The widget to be added to the toolbar.
"""
def __init__(self, label: str | None = None, widget: QWidget = None, parent=None):
def __init__(
self,
label: str | None = None,
widget: QWidget = None,
adjust_size: bool = True,
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):
"""
@@ -343,7 +380,7 @@ class WidgetAction(ToolBarAction):
label_widget.setAlignment(Qt.AlignVCenter | Qt.AlignRight)
layout.addWidget(label_widget)
if isinstance(self.widget, QComboBox):
if isinstance(self.widget, QComboBox) and self.adjust_size:
self.widget.setSizeAdjustPolicy(QComboBox.AdjustToContents)
size_policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
@@ -827,7 +864,7 @@ class MainWindow(QMainWindow): # pragma: no cover
def add_bundles(self):
home_action = MaterialIconAction(
icon_name="home", tooltip="Home", checkable=True, parent=self
icon_name="home", tooltip="Home", checkable=False, parent=self
)
settings_action = MaterialIconAction(
icon_name="settings", tooltip="Settings", checkable=True, parent=self
@@ -844,6 +881,7 @@ 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
@@ -897,20 +935,20 @@ class MainWindow(QMainWindow): # pragma: no cover
def add_switchable_button_checkable(self):
action1 = MaterialIconAction(
icon_name="counter_1", tooltip="Action 1", checkable=True, parent=self
icon_name="hdr_auto", tooltip="Action 1", checkable=True, parent=self
)
action2 = MaterialIconAction(
icon_name="counter_2", tooltip="Action 2", checkable=True, parent=self
icon_name="hdr_auto", tooltip="Action 2", checkable=True, filled=True, parent=self
)
switchable_action = SwitchableToolBarAction(
self.switchable_action = SwitchableToolBarAction(
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
parent=self,
)
self.toolbar.add_action("switchable_action", switchable_action, self)
self.toolbar.add_action("switchable_action", self.switchable_action, self)
action1.action.toggled.connect(
lambda checked: self.test_label.setText(f"Action 1 triggered, checked = {checked}")
@@ -931,16 +969,20 @@ class MainWindow(QMainWindow): # pragma: no cover
actions={"action1": action1, "action2": action2},
initial_action="action1",
tooltip="Switchable Action",
checkable=True,
checkable=False,
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 triggered, checked = {checked}")
lambda checked: self.test_label.setText(
f"Action 1 (non-checkable) triggered, checked = {checked}"
)
)
action2.action.triggered.connect(
lambda checked: self.test_label.setText(f"Action 2 triggered, checked = {checked}")
lambda checked: self.test_label.setText(
f"Action 2 (non-checkable) triggered, checked = {checked}"
)
)
switchable_action.actions["action1"].action.setChecked(True)

View File

@@ -4,6 +4,7 @@ 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
@@ -15,6 +16,7 @@ from qtpy.QtWidgets import QApplication
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.error_popups import ErrorPopupUtility
from bec_widgets.qt_utils.error_popups import SafeSlot as pyqtSlot
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.utils.yaml_dialog import load_yaml, load_yaml_gui, save_yaml, save_yaml_gui
if TYPE_CHECKING:
@@ -39,8 +41,7 @@ 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}_{str(time.time())}"
return v
v = f"{widget_class}_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S_%f')}"
return v
@@ -75,7 +76,13 @@ class BECConnector:
USER_ACCESS = ["_config_dict", "_get_all_rpc", "_rpc_id"]
EXIT_HANDLERS = {}
def __init__(self, client=None, config: ConnectionConfig = None, gui_id: str = None):
def __init__(
self,
client=None,
config: ConnectionConfig | None = None,
gui_id: str | None = None,
name: str | None = None,
):
# BEC related connections
self.bec_dispatcher = BECDispatcher(client=client)
self.client = self.bec_dispatcher.client if client is None else client
@@ -103,15 +110,22 @@ class BECConnector:
)
self.config = ConnectionConfig(widget_class=self.__class__.__name__)
# I feel that we should not allow BECConnector to be created with a custom gui_id
# because this would break with the logic in the RPCRegister of retrieving widgets by type
# iterating over all widgets and checkinf if the register widget starts with the string that is passsed.
# If the gui_id is randomly generated, this would break since that widget would have a
# gui_id that is generated in a different way.
if gui_id:
self.config.gui_id = gui_id
self.gui_id = gui_id
self.gui_id: str = gui_id
else:
self.gui_id = self.config.gui_id
# 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.gui_id: str = self.config.gui_id # type: ignore
if name is None:
name = self.__class__.__name__
else:
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
self._name = name if name else self.__class__.__name__
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self)
@@ -195,6 +209,7 @@ 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.
@@ -207,11 +222,12 @@ 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.
@@ -248,8 +264,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.
@@ -288,9 +304,21 @@ 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 hasattr(self, "close"):
self.close()
if hasattr(self, "deleteLater"):
self.deleteLater()
else:
self.rpc_register.remove_rpc(self)
def get_config(self, dict_output: bool = True) -> dict | BaseModel:
"""

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import darkdetect
from bec_lib.logger import bec_logger
from qtpy.QtCore import Slot
@@ -7,6 +9,10 @@ from qtpy.QtWidgets import QApplication, QWidget
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.containers.dock import BECDock
logger = bec_logger.logger
@@ -17,13 +23,17 @@ 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,
gui_id: str | None = None,
theme_update: bool = False,
name: str | None = None,
parent_dock: BECDock | None = None,
**kwargs,
):
"""
@@ -45,9 +55,9 @@ class BECWidget(BECConnector):
"""
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
super().__init__(client=client, config=config, gui_id=gui_id, name=name)
self._parent_dock = parent_dock
app = QApplication.instance()
if not hasattr(app, "theme"):
# DO NOT SET THE THEME TO AUTO! Otherwise, the qwebengineview will segfault
@@ -88,10 +98,12 @@ class BECWidget(BECConnector):
def cleanup(self):
"""Cleanup the widget."""
# All widgets need to call super().cleanup() in their cleanup method
self.rpc_register.remove_rpc(self)
def closeEvent(self, event):
self.rpc_register.remove_rpc(self)
"""Wrap the close even to ensure the rpc_register is cleaned up."""
try:
self.cleanup()
finally:
super().closeEvent(event)
super().closeEvent(event) # pylint: disable=no-member

View File

@@ -1,30 +1,55 @@
from __future__ import annotations
import itertools
from typing import Type
from typing import Literal, 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 generate_unique_widget_id(container: dict, prefix: str = "widget") -> str:
"""
Generate a unique widget ID.
def has_name_valid_chars(name: str) -> bool:
"""Check if the name is valid.
Args:
container(dict): The container of widgets.
prefix(str): The prefix of the widget ID.
name(str): The name to be checked.
Returns:
widget_id(str): The unique widget ID.
bool: True if the name is valid, False otherwise.
"""
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
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.")
@staticmethod
def find_first_widget_by_class(

View File

@@ -1,4 +1,7 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any
import numpy as np
import pyqtgraph as pg
@@ -197,15 +200,18 @@ 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, y) -> tuple[defaultdict[list], defaultdict[list]]:
def snap_to_data(
self, x: float, y: float
) -> tuple[None, None] | tuple[defaultdict[Any, list], defaultdict[Any, list]]:
"""
Finds the nearest data points to the given x and y coordinates.
Args:
x: The x-coordinate of the mouse cursor
y: The y-coordinate of the mouse cursor
x(float): The x-coordinate of the mouse cursor
y(float): The y-coordinate of the mouse cursor
Returns:
tuple: x and y values snapped to the nearest data
@@ -235,7 +241,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
name = item.config.monitor or str(id(item))
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))
@@ -320,7 +326,7 @@ class Crosshair(QObject):
)
self.coordinatesChanged1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
@@ -374,7 +380,7 @@ class Crosshair(QObject):
)
self.coordinatesClicked1D.emit(coordinate_to_emit)
elif isinstance(item, pg.ImageItem):
name = item.config.monitor
name = item.config.monitor or str(id(item))
x, y = x_snap_values[name], y_snap_values[name]
if x is None or y is None:
continue
@@ -418,9 +424,17 @@ 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(f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})")
self.coord_label.setText(text)
self.coord_label.setPos(x, y)
self.coord_label.setVisible(True)
@@ -436,6 +450,9 @@ 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,7 +22,9 @@ 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")
raise ValueError(
f"Entry '{entry}' not found in device '{name}' signals. Available signals: {description.keys()}"
)
return entry

View File

@@ -148,10 +148,7 @@ class BECTickItem(BECIndicatorItem):
def cleanup(self) -> None:
"""Cleanup the item"""
self.remove_from_plot()
if self.tick_item is not None:
self.tick_item.close()
self.tick_item.deleteLater()
self.tick_item = None
self.tick_item = None
class BECArrowItem(BECIndicatorItem):

View File

@@ -1,25 +1,33 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal, Optional
from typing import TYPE_CHECKING, Any, Literal, Optional, cast
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_register import RPCRegister
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] = Field(
parent_dock_area: Optional[str] | None = Field(
None, description="The GUI ID of parent dock area of the dock."
)
@@ -103,16 +111,17 @@ class BECDock(BECWidget, Dock):
ICON_NAME = "widgets"
USER_ACCESS = [
"_config_dict",
"_rpc_id",
"widget_list",
"element_list",
"elements",
"new",
"show",
"hide",
"show_title_bar",
"hide_title_bar",
"get_widgets_positions",
"set_title",
"add_widget",
"list_eligible_widgets",
"move_widget",
"remove_widget",
"hide_title_bar",
"available_widgets",
"delete",
"delete_all",
"remove",
"attach",
"detach",
@@ -121,7 +130,7 @@ class BECDock(BECWidget, Dock):
def __init__(
self,
parent: QWidget | None = None,
parent_dock_area: QWidget | None = None,
parent_dock_area: BECDockArea | None = None,
config: DockConfig | None = None,
name: str | None = None,
client=None,
@@ -129,21 +138,24 @@ class BECDock(BECWidget, Dock):
closable: bool = True,
**kwargs,
) -> None:
if config is None:
config = DockConfig(
widget_class=self.__class__.__name__, parent_dock_area=parent_dock_area.gui_id
widget_class=self.__class__.__name__,
parent_dock_area=parent_dock_area.gui_id if parent_dock_area else None,
)
else:
if isinstance(config, dict):
config = DockConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(
client=client, config=config, gui_id=gui_id, name=name
) # Name was checked and created in BEC Widget
label = CustomDockLabel(text=name, closable=closable)
Dock.__init__(self, name=name, label=label, **kwargs)
Dock.__init__(self, name=name, label=label, parent=self, **kwargs)
# Dock.__init__(self, name=name, **kwargs)
self.parent_dock_area = parent_dock_area
# Layout Manager
self.layout_manager = GridLayoutManager(self.layout)
@@ -173,7 +185,18 @@ class BECDock(BECWidget, Dock):
super().float()
@property
def widget_list(self) -> list[BECWidget]:
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._name, widget) for widget in self.element_list)
@property
def element_list(self) -> list[BECWidget]:
"""
Get the widgets in the dock.
@@ -182,10 +205,6 @@ 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.
@@ -194,6 +213,20 @@ 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.
@@ -211,7 +244,6 @@ 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:
"""
@@ -222,7 +254,7 @@ class BECDock(BECWidget, Dock):
"""
return self.layout_manager.get_widgets_positions()
def list_eligible_widgets(
def available_widgets(
self,
) -> list: # TODO can be moved to some util mixin like container class for rpc widgets
"""
@@ -233,20 +265,29 @@ class BECDock(BECWidget, Dock):
"""
return list(widget_handler.widget_classes.keys())
def add_widget(
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(
self,
widget: BECWidget | str,
row=None,
col=0,
rowspan=1,
colspan=1,
name: str | None = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
shift: Literal["down", "up", "left", "right"] = "down",
) -> BECWidget:
"""
Add a widget to the dock.
Args:
widget(QWidget): The widget to add.
widget(QWidget): The widget to add. It can not be BECDock or BECDockArea.
name(str): The name of the widget.
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.
@@ -254,15 +295,39 @@ class BECDock(BECWidget, Dock):
shift(Literal["down", "up", "left", "right"]): The direction to shift the widgets if the position is occupied.
"""
if row is None:
# row = cast(int, self.layout.rowCount()) # type:ignore
row = self.layout.rowCount()
# row = cast(int, row)
if self.layout_manager.is_position_occupied(row, col):
self.layout_manager.shift_widgets(shift, start_row=row)
existing_widgets_parent_dock = self._get_list_of_widget_name_of_parent_dock_area()
if name is not None: # Name is provided
if name in existing_widgets_parent_dock:
# pylint: disable=protected-access
raise ValueError(
f"Name {name} must be unique for widgets, but already exists in DockArea "
f"with name: {self.parent_dock_area._name} and id {self.parent_dock_area.gui_id}."
)
else: # Name is not provided
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
name = WidgetContainerUtils.generate_unique_name(
name=widget_class_name, list_of_names=existing_widgets_parent_dock
)
# Check that Widget is not BECDock or BECDockArea
widget_class_name = widget if isinstance(widget, str) else widget.__class__.__name__
if widget_class_name in IGNORE_WIDGETS:
raise ValueError(f"Widget {widget} can not be added to dock.")
if isinstance(widget, str):
widget = widget_handler.create_widget(widget)
widget = cast(
BECWidget,
widget_handler.create_widget(widget_type=widget, name=name, parent_dock=self),
)
else:
widget = widget
widget._name = name # pylint: disable=protected-access
self.addWidget(widget, row=row, col=col, rowspan=rowspan, colspan=colspan)
@@ -294,37 +359,72 @@ class BECDock(BECWidget, Dock):
"""
self.float()
def remove_widget(self, widget_rpc_id: str):
"""
Remove a widget from the dock.
Args:
widget_rpc_id(str): The ID of the widget to remove.
"""
widget = self.rpc_register.get_rpc_by_id(widget_rpc_id)
self.layout.removeWidget(widget)
self.config.widgets.pop(widget_rpc_id, None)
widget.close()
def remove(self):
"""
Remove the dock from the parent dock area.
"""
# self.cleanup()
self.parent_dock_area.remove_dock(self.name())
self.parent_dock_area.delete(self._name)
def delete(self, widget_name: str) -> None:
"""
Remove a widget from the dock.
Args:
widget_name(str): Delete the widget with the given name.
"""
# pylint: disable=protected-access
widgets = [widget for widget in self.widgets if widget._name == widget_name]
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]
self.layout.removeWidget(widget)
self.config.widgets.pop(widget._name, None)
if widget in self.widgets:
self.widgets.remove(widget)
widget.close()
# self._broadcast_update()
def delete_all(self):
"""
Remove all widgets from the dock.
"""
for widget in self.widgets:
self.delete(widget._name) # pylint: disable=protected-access
def cleanup(self):
"""
Clean up the dock, including all its widgets.
"""
for widget in self.widgets:
if hasattr(widget, "cleanup"):
widget.cleanup()
# 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()
self.widgets.clear()
self.label.close()
self.label.deleteLater()
super().cleanup()
# def closeEvent(self, event): # pylint: disable=uselsess-parent-delegation
# """Close Event for dock and cleanup.
# This wrapper ensures that the BECWidget close event is triggered.
# If removed, the closeEvent from pyqtgraph will be triggered, which
# is not calling super().closeEvent(event) and will not trigger the BECWidget close event.
# """
# return super().closeEvent(event)
def close(self):
"""
Close the dock area and cleanup.
@@ -332,4 +432,15 @@ class BECDock(BECWidget, Dock):
"""
self.cleanup()
super().close()
self.parent_dock_area.dock_area.docks.pop(self.name(), None)
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_())

View File

@@ -4,12 +4,14 @@ 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.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.toolbar import (
ExpandableMenuAction,
@@ -23,16 +25,19 @@ 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_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.plots_next_gen.image.image import Image
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
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
logger = bec_logger.logger
class DockAreaConfig(ConnectionConfig):
docks: dict[str, DockConfig] = Field({}, description="The docks in the dock area.")
@@ -44,21 +49,22 @@ class DockAreaConfig(ConnectionConfig):
class BECDockArea(BECWidget, QWidget):
PLUGIN = True
USER_ACCESS = [
"_config_dict",
"selected_device",
"panels",
"save_state",
"remove_dock",
"restore_state",
"add_dock",
"clear_all",
"detach_dock",
"attach_all",
"_get_all_rpc",
"temp_areas",
"all_connections",
"new",
"show",
"hide",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"restore_state",
"list_all_rpc",
"widget_list",
]
def __init__(
@@ -67,6 +73,8 @@ class BECDockArea(BECWidget, QWidget):
config: DockAreaConfig | None = None,
client=None,
gui_id: str = None,
name: str | None = None,
**kwargs,
) -> None:
if config is None:
config = DockAreaConfig(widget_class=self.__class__.__name__)
@@ -74,8 +82,9 @@ class BECDockArea(BECWidget, QWidget):
if isinstance(config, dict):
config = DockAreaConfig(**config)
self.config = config
super().__init__(client=client, config=config, gui_id=gui_id)
super().__init__(client=client, config=config, gui_id=gui_id, name=name, **kwargs)
QWidget.__init__(self, parent=parent)
self._parent = parent
self.layout = QVBoxLayout(self)
self.layout.setSpacing(5)
self.layout.setContentsMargins(0, 0, 0, 0)
@@ -89,8 +98,11 @@ class BECDockArea(BECWidget, QWidget):
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=BECWaveformWidget.ICON_NAME,
tooltip="Add Waveform",
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"scatter_waveform": MaterialIconAction(
icon_name=ScatterWaveform.ICON_NAME,
tooltip="Add Scatter Waveform",
filled=True,
),
"multi_waveform": MaterialIconAction(
@@ -99,12 +111,10 @@ class BECDockArea(BECWidget, QWidget):
filled=True,
),
"image": MaterialIconAction(
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
icon_name=Image.ICON_NAME, tooltip="Add Image", filled=True
),
"motor_map": MaterialIconAction(
icon_name=BECMotorMapWidget.ICON_NAME,
tooltip="Add Motor Map",
filled=True,
icon_name=MotorMap.ICON_NAME, tooltip="Add Motor Map", filled=True
),
},
),
@@ -171,41 +181,44 @@ class BECDockArea(BECWidget, QWidget):
def _hook_toolbar(self):
# Menu Plot
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
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")
)
self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
lambda: self._create_widget_from_toolbar(widget_name="BECMultiWaveformWidget")
)
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
lambda: self._create_widget_from_toolbar(widget_name="Image")
)
self.toolbar.widgets["menu_plots"].widgets["motor_map"].triggered.connect(
lambda: self.add_dock(widget="BECMotorMapWidget", prefix="motor_map")
lambda: self._create_widget_from_toolbar(widget_name="MotorMap")
)
# Menu Devices
self.toolbar.widgets["menu_devices"].widgets["scan_control"].triggered.connect(
lambda: self.add_dock(widget="ScanControl", prefix="scan_control")
lambda: self._create_widget_from_toolbar(widget_name="ScanControl")
)
self.toolbar.widgets["menu_devices"].widgets["positioner_box"].triggered.connect(
lambda: self.add_dock(widget="PositionerBox", prefix="positioner_box")
lambda: self._create_widget_from_toolbar(widget_name="PositionerBox")
)
# Menu Utils
self.toolbar.widgets["menu_utils"].widgets["queue"].triggered.connect(
lambda: self.add_dock(widget="BECQueue", prefix="queue")
lambda: self._create_widget_from_toolbar(widget_name="BECQueue")
)
self.toolbar.widgets["menu_utils"].widgets["status"].triggered.connect(
lambda: self.add_dock(widget="BECStatusBox", prefix="status")
lambda: self._create_widget_from_toolbar(widget_name="BECStatusBox")
)
self.toolbar.widgets["menu_utils"].widgets["vs_code"].triggered.connect(
lambda: self.add_dock(widget="VSCodeEditor", prefix="vs_code")
lambda: self._create_widget_from_toolbar(widget_name="VSCodeEditor")
)
self.toolbar.widgets["menu_utils"].widgets["progress_bar"].triggered.connect(
lambda: self.add_dock(widget="RingProgressBar", prefix="progress_bar")
lambda: self._create_widget_from_toolbar(widget_name="RingProgressBar")
)
self.toolbar.widgets["menu_utils"].widgets["log_panel"].triggered.connect(
lambda: self.add_dock(widget="LogPanel", prefix="log_panel")
lambda: self._create_widget_from_toolbar(widget_name="LogPanel")
)
# Icons
@@ -213,6 +226,11 @@ 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:
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:
@@ -220,7 +238,7 @@ class BECDockArea(BECWidget, QWidget):
painter.drawText(
self.rect(),
Qt.AlignCenter,
"Add docks using 'add_dock' method from CLI\n or \n Add widget docks using the toolbar",
"Add docks using 'new' method from CLI\n or \n Add widget docks using the toolbar",
)
@property
@@ -234,6 +252,12 @@ class BECDockArea(BECWidget, QWidget):
except AttributeError:
return None
@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
@property
def panels(self) -> dict[str, BECDock]:
"""
@@ -245,7 +269,24 @@ class BECDockArea(BECWidget, QWidget):
@panels.setter
def panels(self, value: dict[str, BECDock]):
self.dock_area.docks = WeakValueDictionary(value)
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())
@property
def widget_list(self) -> list:
"""Return a list of all widgets in the application."""
app = QApplication.instance()
all_widgets = app.list_all_bec_widgets()
return all_widgets
@property
def temp_areas(self) -> list:
@@ -289,36 +330,17 @@ 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 add_dock(
def new(
self,
name: str = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = None,
name: str | None = None,
widget: str | QWidget | None = None,
widget_name: str | None = None,
position: Literal["bottom", "top", "left", "right", "above", "below"] = "bottom",
relative_to: BECDock | None = None,
closable: bool = True,
floating: bool = False,
prefix: str = "dock",
widget: str | QWidget | None = None,
row: int = None,
row: int | None = None,
col: int = 0,
rowspan: int = 1,
colspan: int = 1,
@@ -328,12 +350,11 @@ 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.
@@ -342,21 +363,20 @@ class BECDockArea(BECWidget, QWidget):
Returns:
BECDock: The created dock.
"""
if name is None:
name = WidgetContainerUtils.generate_unique_widget_id(
container=self.dock_area.docks, prefix=prefix
)
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_names = [dock._name for dock in self.panel_list] # pylint: disable=protected-access
if name is not None: # Name is provided
if name in dock_names:
raise ValueError(
f"Name {name} must be unique for docks, but already exists in DockArea "
f"with name: {self._name} and id {self.gui_id}."
)
else: # Name is not provided
name = WidgetContainerUtils.generate_unique_name(name="dock", list_of_names=dock_names)
dock = BECDock(name=name, parent_dock_area=self, closable=closable)
dock.config.position = position
self.config.docks[name] = dock.config
self.config.docks[dock.name()] = dock.config
# The dock.name is equal to the name passed to BECDock
self.dock_area.addDock(dock=dock, position=position, relativeTo=relative_to)
if len(self.dock_area.docks) <= 1:
@@ -365,10 +385,11 @@ class BECDockArea(BECWidget, QWidget):
for dock in self.dock_area.docks.values():
dock.show_title_bar()
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 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 (
self._instructions_visible
): # TODO still decide how initial instructions should be handled
@@ -406,49 +427,26 @@ 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.
"""
self.clear_all()
self.delete_all()
self.toolbar.close()
self.toolbar.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()
@@ -467,18 +465,62 @@ class BECDockArea(BECWidget, QWidget):
continue
docks.window().hide()
def delete(self):
self.hide()
self.deleteLater()
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 list_all_rpc(self) -> dict:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
rpc_register = RPCRegister()
all_connections = rpc_register.list_all_connections()
return all_connections
def remove(self) -> None:
"""Remove the dock area."""
self.close()
if __name__ == "__main__":
from qtpy.QtWidgets import QApplication
if __name__ == "__main__": # pragma: no cover
import sys
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="Waveform")
# dock_1 = dock_area.new(name="dock_0", widget="Waveform")
dock_area.new(widget="Waveform")
dock_area.show()
dock_area.setGeometry(100, 100, 800, 600)
app.topLevelWidgets()
app.exec_()
sys.exit(app.exec_())

View File

@@ -78,13 +78,7 @@ class WidgetHandler:
}
def create_widget(
self,
widget_type: str,
widget_id: str,
parent_figure,
parent_id: str,
config: dict = None,
**axis_kwargs,
self, widget_type: str, parent_figure, parent_id: str, config: dict = None, **axis_kwargs
) -> BECPlotBase:
"""
Create and configure a widget based on its type.
@@ -109,7 +103,6 @@ class WidgetHandler:
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)
@@ -568,13 +561,13 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
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_id = widget.gui_id
widget.config.row = row
widget.config.col = col

View File

@@ -33,6 +33,7 @@ class ImageConfig(SubplotConfig):
)
# TODO old version will be deprecated
class BECImageShow(BECPlotBase):
USER_ACCESS = [
"_rpc_id",

View File

@@ -41,6 +41,7 @@ class ImageItemConfig(ConnectionConfig):
)
# TODO old version will be deprecated
class BECImageItem(BECConnector, pg.ImageItem):
USER_ACCESS = [
"_rpc_id",

View File

@@ -7,6 +7,8 @@ import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal, Slot
# TODO will be deleted
@dataclass
class ImageStats:

View File

@@ -0,0 +1,262 @@
<?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,13 +1,108 @@
from __future__ import annotations
import os
import sys
from bec_lib.logger import bec_logger
from PySide6.QtCore import QSize
from PySide6.QtGui import QAction, QActionGroup, QIcon
from PySide6.QtWidgets import QFileDialog, QMessageBox, QStyle, QWidget
from bec_widgets.examples.qapp_custom.bec_qapp import upgrade_to_becqapp, BECQApplication
from bec_widgets.utils import UILoader
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.utils import BECConnector
import bec_widgets
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.qt_utils.toolbar import MaterialIconAction
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
from bec_widgets.widgets.containers.main_window.web_links import BECWebLinksMixin
logger = bec_logger.logger
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
class BECMainWindow(BECWidget, QMainWindow):
USER_ACCESS = ["new_dock_area", "change_theme", "list_all_rpc"]
def __init__(
self, gui_id: str = None, name: str = None, default_widget=QWidget, *args, **kwargs
):
BECWidget.__init__(self, gui_id=gui_id, name=name, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
# Upgrade qApp if necessary
self.app = QApplication.instance()
self._upgrade_qapp()
self._init_ui()
def _upgrade_qapp(self):
if not getattr(self.app, "is_bec_app", False):
print("[BECWidget]: Upgrading QApplication instance to BECQApplication.")
self.app = upgrade_to_becqapp()
else:
print("[BECWidget]: BECQApplication already active.")
self.app.inject_property("widget_initialized", True)
def _init_ui(self):
# Set the window title
self.setWindowTitle("BEC")
# Set Menu and Status bar
self._setup_menu_bar()
self.statusBar().showMessage(f"App ID: {self.app.gui_id}")
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
# TODO can be fetched from app
light_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)
def _dump(self):
"""Return a dictionary with informations about the application state, for use in tests"""
@@ -33,9 +128,112 @@ class BECMainWindow(QMainWindow, BECConnector):
}
return info
def new_dock_area(self, name):
dock_area = BECDockArea()
dock_area.resize(dock_area.minimumSizeHint())
dock_area.window().setWindowTitle(name)
dock_area.show()
def change_theme(self, theme):
apply_theme(theme)
def new_dock_area(
self, name: str | None = None, geometry: tuple[int, int, int, int] | None = None
) -> BECDockArea:
"""Create a new dock area.
Args:
name(str): The name of the dock area.
geometry(tuple): The geometry parameters to be passed to the dock area.
Returns:
BECDockArea: The newly created dock area.
"""
rpc_register = RPCRegister()
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)
new_q_main_window = BECMainWindow(gui_id=name)
dock_area = BECDockArea(name=name)
new_q_main_window.setCentralWidget(dock_area)
new_q_main_window.resize(dock_area.minimumSizeHint())
# TODO Should we simply use the specified name as title here?
new_q_main_window.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:
new_q_main_window.setGeometry(*geometry)
new_q_main_window.show()
return dock_area
def list_all_rpc(self) -> list:
"""
List all the registered RPC objects.
Returns:
dict: A dictionary containing all the registered RPC objects.
"""
all_connections = self.rpc_register.list_all_connections()
all_connections_keys = list(all_connections.keys())
return all_connections_keys
def cleanup(self):
super().close()
class WindowWithUi(BECMainWindow):
"""
A class that represents a window with a user interface.
It inherits from BECMainWindow and provides additional functionality.
"""
USER_ACCESS = [
"new_dock_area",
"all_connections",
"change_theme",
"list_all_rpc",
"dock_area",
"register_all_rpc",
"widget_list",
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
ui_file_path = os.path.join(os.path.dirname(__file__), "general_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 dock_area(self):
dock_area = self.ui.dock_area
return dock_area
@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 register_all_rpc(self):
app = QApplication.instance()
app.register_all()
@property
def widget_list(self) -> list:
"""Return a list of all widgets in the application."""
app = QApplication.instance()
all_widgets = app.list_all_bec_widgets()
return all_widgets
if __name__ == "__main__":
app = BECQApplication(sys.argv)
window = WindowWithUi()
window.resize(1280, 720)
window.show()
sys.exit(app.exec())

View File

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

View File

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

View File

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

View File

@@ -1,515 +0,0 @@
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

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

View File

@@ -1,56 +0,0 @@
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 MotorMapSettings(SettingWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, "motor_map_settings.ui"))
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
@Slot(dict)
def display_current_settings(self, config: dict):
WidgetIO.set_value(self.ui.max_points, config["max_points"])
WidgetIO.set_value(self.ui.trace_dim, config["num_dim_points"])
WidgetIO.set_value(self.ui.precision, config["precision"])
WidgetIO.set_value(self.ui.scatter_size, config["scatter_size"])
background_intensity = int((config["background_value"] / 255) * 100)
WidgetIO.set_value(self.ui.background_value, background_intensity)
color = config["color"]
self.ui.color.set_color(color)
@Slot()
def accept_changes(self):
max_points = WidgetIO.get_value(self.ui.max_points)
num_dim_points = WidgetIO.get_value(self.ui.trace_dim)
precision = WidgetIO.get_value(self.ui.precision)
scatter_size = WidgetIO.get_value(self.ui.scatter_size)
background_intensity = int(WidgetIO.get_value(self.ui.background_value) * 0.01 * 255)
color = self.ui.color.get_color("RGBA")
if self.target_widget is not None:
self.target_widget.set_max_points(max_points)
self.target_widget.set_num_dim_points(num_dim_points)
self.target_widget.set_precision(precision)
self.target_widget.set_scatter_size(scatter_size)
self.target_widget.set_background_value(background_intensity)
self.target_widget.set_color(color)
def cleanup(self):
self.ui.color.cleanup()
self.ui.color.close()
self.ui.color.deleteLater()
def closeEvent(self, event):
self.cleanup()
super().closeEvent(event)

View File

@@ -1,108 +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>243</width>
<height>233</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="3" column="1">
<widget class="QSpinBox" name="scatter_size">
<property name="maximum">
<number>20</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trace_label">
<property name="text">
<string>Trace Dim</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="precision_label">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="background_label">
<property name="text">
<string>Background Intensity</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="precision">
<property name="maximum">
<number>15</number>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="background_value">
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="max_point_label">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="scatter_size_label">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="max_points">
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="trace_dim">
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="color_label">
<property name="text">
<string>Color</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="ColorButton" name="color"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ColorButton</class>
<extends>QPushButton</extends>
<header>color_button</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,234 +0,0 @@
from __future__ import annotations
import sys
from qtpy.QtWidgets import QVBoxLayout, QWidget
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import DeviceSelectionAction, MaterialIconAction, ModularToolBar
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.widgets.containers.figure import BECFigure
from bec_widgets.widgets.containers.figure.plots.motor_map.motor_map import MotorMapConfig
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
from bec_widgets.widgets.plots.motor_map.motor_map_dialog.motor_map_settings import MotorMapSettings
class BECMotorMapWidget(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "my_location"
USER_ACCESS = [
"change_motors",
"set_max_points",
"set_precision",
"set_num_dim_points",
"set_background_value",
"set_scatter_size",
"get_data",
"reset_history",
"export",
]
def __init__(
self,
parent: QWidget | None = None,
config: MotorMapConfig | None = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = MotorMapConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = MotorMapConfig(**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.toolbar = ModularToolBar(
actions={
"motor_x": DeviceSelectionAction(
"Motor X:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"motor_y": DeviceSelectionAction(
"Motor Y:", DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
),
"connect": MaterialIconAction(icon_name="link", tooltip="Connect Motors"),
"history": MaterialIconAction(icon_name="history", tooltip="Reset Trace History"),
"config": MaterialIconAction(
icon_name="settings", tooltip="Open Configuration Dialog"
),
},
target_widget=self,
)
self.layout.addWidget(self.toolbar)
self.layout.addWidget(self.fig)
self.map = self.fig.motor_map()
self.map.apply_config(config)
self._hook_actions()
self.config = config
def _hook_actions(self):
self.toolbar.widgets["connect"].action.triggered.connect(self._action_motors)
self.toolbar.widgets["config"].action.triggered.connect(self.show_settings)
self.toolbar.widgets["history"].action.triggered.connect(self.reset_history)
if self.map.motor_x is None and self.map.motor_y is None:
self._enable_actions(False)
def _enable_actions(self, enable: bool):
self.toolbar.widgets["config"].action.setEnabled(enable)
self.toolbar.widgets["history"].action.setEnabled(enable)
def _action_motors(self):
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
motor_x = toolbar_x.currentText()
motor_y = toolbar_y.currentText()
self.change_motors(motor_x, motor_y, None, None, True)
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
def show_settings(self) -> None:
dialog = SettingsDialog(
self, settings_widget=MotorMapSettings(), window_title="Motor Map Settings"
)
dialog.exec()
###################################
# User Access Methods from MotorMap
###################################
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.map.change_motors(motor_x, motor_y, motor_x_entry, motor_y_entry, validate_bec)
if self.map.motor_x is not None and self.map.motor_y is not None:
self._enable_actions(True)
toolbar_x = self.toolbar.widgets["motor_x"].device_combobox
toolbar_y = self.toolbar.widgets["motor_y"].device_combobox
if toolbar_x.currentText() != motor_x:
toolbar_x.setCurrentText(motor_x)
toolbar_x.setStyleSheet("QComboBox {{ background-color: " "; }}")
if toolbar_y.currentText() != motor_y:
toolbar_y.setCurrentText(motor_y)
toolbar_y.setStyleSheet("QComboBox {{ background-color: " "; }}")
def get_data(self) -> dict:
"""
Get the data of the motor map.
Returns:
dict: Data of the motor map.
"""
return self.map.get_data()
def reset_history(self) -> None:
"""
Reset the history of the motor map.
"""
self.map.reset_history()
def set_color(self, color: str | tuple):
"""
Set the color of the motor map.
Args:
color(str, tuple): Color to set.
"""
self.map.set_color(color)
def set_max_points(self, max_points: int) -> None:
"""
Set the maximum number of points to display on the motor map.
Args:
max_points(int): Maximum number of points to display.
"""
self.map.set_max_points(max_points)
def set_precision(self, precision: int) -> None:
"""
Set the precision of the motor map.
Args:
precision(int): Precision to set.
"""
self.map.set_precision(precision)
def set_num_dim_points(self, num_dim_points: int) -> None:
"""
Set the number of points to display on the motor map.
Args:
num_dim_points(int): Number of points to display.
"""
self.map.set_num_dim_points(num_dim_points)
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.map.set_background_value(background_value)
def set_scatter_size(self, scatter_size: int) -> None:
"""
Set the scatter size of the motor map.
Args:
scatter_size(int): Scatter size of the motor map.
"""
self.map.set_scatter_size(scatter_size)
def export(self):
"""
Show the export dialog for the motor map.
"""
self.map.export()
def cleanup(self):
self.fig.cleanup()
self.toolbar.widgets["motor_x"].device_combobox.cleanup()
self.toolbar.widgets["motor_y"].device_combobox.cleanup()
return super().cleanup()
def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECMotorMapWidget()
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

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

View File

@@ -1,336 +0,0 @@
from __future__ import annotations
import os
from typing import Literal
from bec_qthemes import material_icon
from pydantic import BaseModel
from qtpy.QtCore import QObject, Slot
from qtpy.QtWidgets import QComboBox, QLineEdit, QPushButton, QSpinBox, QTableWidget, QVBoxLayout
import bec_widgets
from bec_widgets.qt_utils.error_popups import WarningPopupUtility
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import Colors, UILoader
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.utility.visual.color_button.color_button import ColorButton
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class CurveSettings(SettingWidget):
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent, *args, **kwargs)
current_path = os.path.dirname(__file__)
self.ui = UILoader(self).loader(os.path.join(current_path, "curve_dialog.ui"))
self._setup_icons()
self.warning_util = WarningPopupUtility(self)
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.ui)
self.ui.add_curve.clicked.connect(self.add_curve)
self.ui.add_dap.clicked.connect(self.add_dap)
self.ui.x_mode.currentIndexChanged.connect(self.set_x_mode)
self.ui.normalize_colors_scan.clicked.connect(lambda: self.change_colormap("scan"))
self.ui.normalize_colors_dap.clicked.connect(lambda: self.change_colormap("dap"))
def _setup_icons(self):
add_icon = material_icon(icon_name="add", size=(20, 20), convert_to_pixmap=False)
self.ui.add_dap.setIcon(add_icon)
self.ui.add_dap.setToolTip("Add DAP Curve")
self.ui.add_curve.setIcon(add_icon)
self.ui.add_curve.setToolTip("Add Scan Curve")
@Slot(dict)
def display_current_settings(self, config: dict | BaseModel):
# What elements should be enabled
x_name = self.target_widget.waveform._x_axis_mode["name"]
x_entry = self.target_widget.waveform._x_axis_mode["entry"]
self._enable_ui_elements(x_name, x_entry)
cm = self.target_widget.config.color_palette
self.ui.color_map_selector_scan.colormap = cm
# Scan Curve Table
for source in ["scan_segment", "async"]:
for label, curve in config[source].items():
row_count = self.ui.scan_table.rowCount()
self.ui.scan_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.scan_table,
client=self.target_widget.client,
row=row_count,
config=curve.config,
).add_scan_row()
# Add DAP Curves
for label, curve in config["DAP"].items():
row_count = self.ui.dap_table.rowCount()
self.ui.dap_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.dap_table,
client=self.target_widget.client,
row=row_count,
config=curve.config,
).add_dap_row()
def _enable_ui_elements(self, name, entry):
if name is None:
name = "best_effort"
if name in ["index", "timestamp", "best_effort"]:
self.ui.x_mode.setCurrentText(name)
self.set_x_mode()
else:
self.ui.x_mode.setCurrentText("device")
self.set_x_mode()
self.ui.x_name.setText(name)
self.ui.x_entry.setText(entry)
@Slot()
def set_x_mode(self):
x_mode = self.ui.x_mode.currentText()
if x_mode in ["index", "timestamp", "best_effort"]:
self.ui.x_name.setEnabled(False)
self.ui.x_entry.setEnabled(False)
self.ui.dap_table.setEnabled(False)
self.ui.add_dap.setEnabled(False)
if self.ui.dap_table.rowCount() > 0:
self.warning_util.show_warning(
title="DAP Warning",
message="DAP is not supported without specific x-axis device. All current DAP curves will be removed.",
detailed_text=f"Affected curves: {[self.ui.dap_table.cellWidget(row, 0).text() for row in range(self.ui.dap_table.rowCount())]}",
)
else:
self.ui.x_name.setEnabled(True)
self.ui.x_entry.setEnabled(True)
self.ui.dap_table.setEnabled(True)
self.ui.add_dap.setEnabled(True)
@Slot()
def change_colormap(self, target: Literal["scan", "dap"]):
if target == "scan":
cm = self.ui.color_map_selector_scan.colormap
table = self.ui.scan_table
if target == "dap":
cm = self.ui.color_map_selector_dap.colormap
table = self.ui.dap_table
rows = table.rowCount()
colors = Colors.golden_angle_color(colormap=cm, num=max(10, rows + 1), format="HEX")
color_button_col = 2 if target == "scan" else 3
for row in range(rows):
table.cellWidget(row, color_button_col).set_color(colors[row])
@Slot()
def accept_changes(self):
self.accept_curve_changes()
def accept_curve_changes(self):
sources = ["scan_segment", "async", "DAP"]
old_curves = []
for source in sources:
old_curves += list(self.target_widget.waveform._curves_data[source].values())
for curve in old_curves:
curve.remove()
self.get_curve_params()
def get_curve_params(self):
x_mode = self.ui.x_mode.currentText()
if x_mode in ["index", "timestamp", "best_effort"]:
x_name = x_mode
x_entry = x_mode
else:
x_name = self.ui.x_name.text()
x_entry = self.ui.x_entry.text()
self.target_widget.set_x(x_name=x_name, x_entry=x_entry)
for row in range(self.ui.scan_table.rowCount()):
y_name = self.ui.scan_table.cellWidget(row, 0).text()
y_entry = self.ui.scan_table.cellWidget(row, 1).text()
color = self.ui.scan_table.cellWidget(row, 2).get_color()
style = self.ui.scan_table.cellWidget(row, 3).currentText()
width = self.ui.scan_table.cellWidget(row, 4).value()
symbol_size = self.ui.scan_table.cellWidget(row, 5).value()
self.target_widget.plot(
y_name=y_name,
y_entry=y_entry,
color=color,
pen_style=style,
pen_width=width,
symbol_size=symbol_size,
)
if x_mode not in ["index", "timestamp", "best_effort"]:
for row in range(self.ui.dap_table.rowCount()):
y_name = self.ui.dap_table.cellWidget(row, 0).text()
y_entry = self.ui.dap_table.cellWidget(row, 1).text()
dap = self.ui.dap_table.cellWidget(row, 2).currentText()
color = self.ui.dap_table.cellWidget(row, 3).get_color()
style = self.ui.dap_table.cellWidget(row, 4).currentText()
width = self.ui.dap_table.cellWidget(row, 5).value()
symbol_size = self.ui.dap_table.cellWidget(row, 6).value()
self.target_widget.add_dap(
x_name=x_name,
x_entry=x_entry,
y_name=y_name,
y_entry=y_entry,
dap=dap,
color=color,
pen_style=style,
pen_width=width,
symbol_size=symbol_size,
)
self.target_widget.scan_history(-1)
def add_curve(self):
row_count = self.ui.scan_table.rowCount()
self.ui.scan_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.scan_table,
client=self.target_widget.client,
row=row_count,
config=None,
).add_scan_row()
def add_dap(self):
row_count = self.ui.dap_table.rowCount()
self.ui.dap_table.insertRow(row_count)
DialogRow(
parent=self,
table_widget=self.ui.dap_table,
client=self.target_widget.client,
row=row_count,
config=None,
).add_dap_row()
class DialogRow(QObject):
def __init__(
self,
parent=None,
table_widget: QTableWidget = None,
row: int = None,
config: dict = None,
client=None,
):
super().__init__(parent=parent)
self.client = client
self.table_widget = table_widget
self.row = row
self.config = config
self.init_default_widgets()
def init_default_widgets(self):
# Remove Button
self.remove_button = RemoveButton()
# Name and Entry
self.device_line_edit = DeviceLineEdit()
self.entry_line_edit = QLineEdit()
self.dap_combo = DapComboBox()
self.dap_combo.populate_fit_model_combobox()
self.dap_combo.select_fit_model("GaussianModel")
# Styling
self.color_button = ColorButton()
self.style_combo = StyleComboBox()
self.width = QSpinBox()
self.width.setMinimum(1)
self.width.setMaximum(20)
self.width.setValue(4)
self.symbol_size = QSpinBox()
self.symbol_size.setMinimum(1)
self.symbol_size.setMaximum(20)
self.symbol_size.setValue(7)
self.remove_button.clicked.connect(
lambda: self.remove_row()
) # From some reason do not work without lambda
def add_scan_row(self):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
self.entry_line_edit.setText(self.config.signals.y.entry)
self.color_button.set_color(self.config.color)
self.style_combo.setCurrentText(self.config.pen_style)
self.width.setValue(self.config.pen_width)
self.symbol_size.setValue(self.config.symbol_size)
else:
default_colors = Colors.golden_angle_color(
colormap="magma", num=max(10, self.row + 1), format="HEX"
)
default_color = default_colors[self.row]
self.color_button.set_color(default_color)
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
self.table_widget.setCellWidget(self.row, 2, self.color_button)
self.table_widget.setCellWidget(self.row, 3, self.style_combo)
self.table_widget.setCellWidget(self.row, 4, self.width)
self.table_widget.setCellWidget(self.row, 5, self.symbol_size)
self.table_widget.setCellWidget(self.row, 6, self.remove_button)
def add_dap_row(self):
if self.config is not None:
self.device_line_edit.setText(self.config.signals.y.name)
self.entry_line_edit.setText(self.config.signals.y.entry)
self.dap_combo.fit_model_combobox.setCurrentText(self.config.signals.dap)
self.color_button.set_color(self.config.color)
self.style_combo.setCurrentText(self.config.pen_style)
self.width.setValue(self.config.pen_width)
self.symbol_size.setValue(self.config.symbol_size)
else:
default_colors = Colors.golden_angle_color(
colormap="magma", num=max(10, self.row + 1), format="HEX"
)
default_color = default_colors[self.row]
self.color_button.set_color(default_color)
self.table_widget.setCellWidget(self.row, 0, self.device_line_edit)
self.table_widget.setCellWidget(self.row, 1, self.entry_line_edit)
self.table_widget.setCellWidget(self.row, 2, self.dap_combo.fit_model_combobox)
self.table_widget.setCellWidget(self.row, 3, self.color_button)
self.table_widget.setCellWidget(self.row, 4, self.style_combo)
self.table_widget.setCellWidget(self.row, 5, self.width)
self.table_widget.setCellWidget(self.row, 6, self.symbol_size)
self.table_widget.setCellWidget(self.row, 7, self.remove_button)
@Slot()
def remove_row(self):
row = self.table_widget.indexAt(self.remove_button.pos()).row()
self.cleanup()
self.table_widget.removeRow(row)
def cleanup(self):
self.device_line_edit.cleanup()
class StyleComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
self.addItems(["solid", "dash", "dot", "dashdot"])
class RemoveButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
icon = material_icon("disabled_by_default", size=(20, 20), convert_to_pixmap=False)
self.setIcon(icon)

View File

@@ -1,372 +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>720</width>
<height>806</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout_2">
<property name="leftMargin">
<number>2</number>
</property>
<property name="topMargin">
<number>2</number>
</property>
<property name="rightMargin">
<number>2</number>
</property>
<property name="bottomMargin">
<number>2</number>
</property>
<item>
<widget class="QGroupBox" name="x_group_box">
<property name="title">
<string>X Axis</string>
</property>
<layout class="QHBoxLayout" name="horizontalLayout" stretch="0,0,0,0,1,3,1,3">
<item>
<widget class="QLabel" name="x_mode_label">
<property name="text">
<string>X Axis Mode</string>
</property>
</widget>
</item>
<item>
<widget class="QComboBox" name="x_mode">
<property name="minimumSize">
<size>
<width>150</width>
<height>26</height>
</size>
</property>
<item>
<property name="text">
<string>best_effort</string>
</property>
</item>
<item>
<property name="text">
<string>device</string>
</property>
</item>
<item>
<property name="text">
<string>index</string>
</property>
</item>
<item>
<property name="text">
<string>timestamp</string>
</property>
</item>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<spacer name="horizontalSpacer_4">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="x_name_label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item>
<widget class="DeviceLineEdit" name="x_name"/>
</item>
<item>
<widget class="QLabel" name="x_entry_label">
<property name="text">
<string>Entry</string>
</property>
</widget>
</item>
<item>
<widget class="QLineEdit" name="x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="y_group_box">
<property name="title">
<string>Y Axis</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QTabWidget" name="tabWidget">
<property name="currentIndex">
<number>0</number>
</property>
<widget class="QWidget" name="tab_scan">
<attribute name="title">
<string>Scan</string>
</attribute>
<layout class="QGridLayout" name="gridLayout">
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item row="0" column="2">
<widget class="QPushButton" name="normalize_colors_scan">
<property name="text">
<string>Normalize Colors</string>
</property>
</widget>
</item>
<item row="1" column="0" colspan="4">
<widget class="QTableWidget" name="scan_table">
<property name="rowCount">
<number>0</number>
</property>
<attribute name="horizontalHeaderCascadingSectionResizes">
<bool>false</bool>
</attribute>
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<attribute name="verticalHeaderStretchLastSection">
<bool>false</bool>
</attribute>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entry</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
<column>
<property name="text">
<string>Style</string>
</property>
</column>
<column>
<property name="text">
<string>Width</string>
</property>
</column>
<column>
<property name="text">
<string>Symbol Size</string>
</property>
</column>
<column>
<property name="text">
<string>Delete</string>
</property>
</column>
</widget>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="add_curve">
<property name="toolTip">
<string/>
</property>
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="BECColorMapWidget" name="color_map_selector_scan">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="tab_DAP">
<attribute name="title">
<string>DAP</string>
</attribute>
<layout class="QGridLayout" name="gridLayout_2">
<property name="leftMargin">
<number>5</number>
</property>
<property name="topMargin">
<number>5</number>
</property>
<property name="rightMargin">
<number>5</number>
</property>
<property name="bottomMargin">
<number>5</number>
</property>
<item row="1" column="0" colspan="4">
<widget class="QTableWidget" name="dap_table">
<attribute name="horizontalHeaderShowSortIndicator" stdset="0">
<bool>true</bool>
</attribute>
<attribute name="horizontalHeaderStretchLastSection">
<bool>true</bool>
</attribute>
<column>
<property name="text">
<string>Name</string>
</property>
</column>
<column>
<property name="text">
<string>Entry</string>
</property>
</column>
<column>
<property name="text">
<string>Model</string>
</property>
</column>
<column>
<property name="text">
<string>Color</string>
</property>
</column>
<column>
<property name="text">
<string>Style</string>
</property>
</column>
<column>
<property name="text">
<string>Width</string>
</property>
</column>
<column>
<property name="text">
<string>Symbol Size</string>
</property>
</column>
<column>
<property name="text">
<string>Delete</string>
</property>
</column>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="normalize_colors_dap">
<property name="text">
<string>Normalize Colors</string>
</property>
</widget>
</item>
<item row="0" column="1">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>585</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="add_dap">
<property name="text">
<string/>
</property>
</widget>
</item>
<item row="0" column="3">
<widget class="BECColorMapWidget" name="color_map_selector_dap">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceLineEdit</class>
<extends>QLineEdit</extends>
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -1,25 +0,0 @@
from qtpy.QtWidgets import QDialog, QVBoxLayout
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
class FitSummaryWidget(QDialog):
def __init__(self, parent=None, target_widget=None):
super().__init__(parent=parent)
self.setModal(True)
self.target_widget = target_widget
self.dap_dialog = LMFitDialog(parent=self, ui_file="lmfit_dialog_compact.ui")
self.layout = QVBoxLayout(self)
self.layout.addWidget(self.dap_dialog)
self.target_widget.dap_summary_update.connect(self.dap_dialog.update_summary_tree)
self.setLayout(self.layout)
self._get_dap_from_target_widget()
def _get_dap_from_target_widget(self) -> None:
"""Get the DAP data from the target widget and update the DAP dialog manually on creation."""
dap_summary = self.target_widget.get_dap_summary()
for curve_id, data in dap_summary.items():
md = {"curve_id": curve_id}
self.dap_dialog.update_summary_tree(data=data, metadata=md)

View File

@@ -1,751 +0,0 @@
from __future__ import annotations
import sys
from typing import Literal
import numpy as np
import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import Property, Signal, Slot
from qtpy.QtWidgets import 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 MaterialIconAction, ModularToolBar, SeparatorAction
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.waveform.waveform import Waveform1DConfig
from bec_widgets.widgets.containers.figure.plots.waveform.waveform_curve import BECCurve
from bec_widgets.widgets.plots.waveform.waveform_popups.curve_dialog.curve_dialog import (
CurveSettings,
)
from bec_widgets.widgets.plots.waveform.waveform_popups.dap_summary_dialog.dap_summary_dialog import (
FitSummaryWidget,
)
try:
import pandas as pd
except ImportError:
pd = None
logger = bec_logger.logger
class BECWaveformWidget(BECWidget, QWidget):
PLUGIN = True
ICON_NAME = "show_chart"
USER_ACCESS = [
"curves",
"plot",
"add_dap",
"get_dap_params",
"remove_curve",
"scan_history",
"get_all_data",
"set",
"set_x",
"set_title",
"set_x_label",
"set_y_label",
"set_x_scale",
"set_y_scale",
"set_x_lim",
"set_y_lim",
"set_legend_label_size",
"set_auto_range",
"set_grid",
"enable_fps_monitor",
"enable_scatter",
"lock_aspect_ratio",
"export",
"export_to_matplotlib",
"toggle_roi",
"select_roi",
]
scan_signal_update = Signal()
async_signal_update = Signal()
dap_summary_update = Signal(dict, dict)
dap_params_update = Signal(dict, dict)
autorange_signal = Signal()
new_scan = Signal()
crosshair_position_changed = Signal(tuple)
crosshair_position_changed_string = Signal(str)
crosshair_position_clicked = Signal(tuple)
crosshair_position_clicked_string = Signal(str)
crosshair_coordinates_changed = Signal(tuple)
crosshair_coordinates_changed_string = Signal(str)
crosshair_coordinates_clicked = Signal(tuple)
crosshair_coordinates_clicked_string = Signal(str)
roi_changed = Signal(tuple)
roi_active = Signal(bool)
def __init__(
self,
parent: QWidget | None = None,
config: Waveform1DConfig | dict = None,
client=None,
gui_id: str | None = None,
**kwargs,
) -> None:
if config is None:
config = Waveform1DConfig(widget_class=self.__class__.__name__)
else:
if isinstance(config, dict):
config = Waveform1DConfig(**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.toolbar = ModularToolBar(
actions={
"save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
"matplotlib": MaterialIconAction(
icon_name="photo_library", tooltip="Open Matplotlib Plot"
),
"separator_1": SeparatorAction(),
"drag_mode": MaterialIconAction(
icon_name="drag_pan", 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"
),
"separator_2": SeparatorAction(),
"curves": MaterialIconAction(
icon_name="timeline", tooltip="Open Curves Configuration"
),
"fit_params": MaterialIconAction(
icon_name="monitoring", tooltip="Open Fitting Parameters"
),
"separator_3": SeparatorAction(),
"crosshair": MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
),
"roi_select": MaterialIconAction(
icon_name="align_justify_space_between",
tooltip="Add ROI region for DAP",
checkable=True,
),
"separator_4": 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.waveform = self.fig.plot()
self.waveform.apply_config(config)
self.config = config
self._clear_curves_on_plot_update = False
self.hook_waveform_signals()
self._hook_actions()
def hook_waveform_signals(self):
self.waveform.scan_signal_update.connect(self.scan_signal_update)
self.waveform.async_signal_update.connect(self.async_signal_update)
self.waveform.dap_params_update.connect(self.dap_params_update)
self.waveform.dap_summary_update.connect(self.dap_summary_update)
self.waveform.autorange_signal.connect(self.autorange_signal)
self.waveform.new_scan.connect(self.new_scan)
self.waveform.crosshair_coordinates_changed.connect(self.crosshair_coordinates_changed)
self.waveform.crosshair_coordinates_clicked.connect(self.crosshair_coordinates_clicked)
self.waveform.crosshair_coordinates_changed.connect(
self._emit_crosshair_coordinates_changed_string
)
self.waveform.crosshair_coordinates_clicked.connect(
self._emit_crosshair_coordinates_clicked_string
)
self.waveform.crosshair_position_changed.connect(self.crosshair_position_changed)
self.waveform.crosshair_position_clicked.connect(self.crosshair_position_clicked)
self.waveform.crosshair_position_changed.connect(
self._emit_crosshair_position_changed_string
)
self.waveform.crosshair_position_clicked.connect(
self._emit_crosshair_position_clicked_string
)
self.waveform.roi_changed.connect(self.roi_changed)
self.waveform.roi_active.connect(self.roi_active)
def _hook_actions(self):
self.toolbar.widgets["save"].action.triggered.connect(self.export)
self.toolbar.widgets["matplotlib"].action.triggered.connect(self.export_to_matplotlib)
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._auto_range_from_toolbar)
self.toolbar.widgets["curves"].action.triggered.connect(self.show_curve_settings)
self.toolbar.widgets["fit_params"].action.triggered.connect(self.show_fit_summary_dialog)
self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
self.toolbar.widgets["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair)
self.toolbar.widgets["roi_select"].action.toggled.connect(self.waveform.toggle_roi)
self.toolbar.widgets["fps_monitor"].action.toggled.connect(self.enable_fps_monitor)
# self.toolbar.widgets["import"].action.triggered.connect(
# lambda: self.load_config(path=None, gui=True)
# )
# self.toolbar.widgets["export"].action.triggered.connect(
# lambda: self.save_config(path=None, gui=True)
# )
@Slot(bool)
def toogle_roi_select(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.toolbar.widgets["roi_select"].action.setChecked(checked)
@Property(bool)
def clear_curves_on_plot_update(self) -> bool:
"""If True, clear curves on plot update."""
return self._clear_curves_on_plot_update
@clear_curves_on_plot_update.setter
def clear_curves_on_plot_update(self, value: bool):
"""Set the clear curves on plot update property.
Args:
value(bool): If True, clear curves on plot update.
"""
self._clear_curves_on_plot_update = value
@SafeSlot(tuple)
def _emit_crosshair_coordinates_changed_string(self, coordinates):
self.crosshair_coordinates_changed_string.emit(str(coordinates))
@SafeSlot(tuple)
def _emit_crosshair_coordinates_clicked_string(self, coordinates):
self.crosshair_coordinates_clicked_string.emit(str(coordinates))
@SafeSlot(tuple)
def _emit_crosshair_position_changed_string(self, position):
self.crosshair_position_changed_string.emit(str(position))
@SafeSlot(tuple)
def _emit_crosshair_position_clicked_string(self, position):
self.crosshair_position_clicked_string.emit(str(position))
###################################
# Dialog Windows
###################################
def show_axis_settings(self):
dialog = SettingsDialog(
self,
settings_widget=AxisSettings(),
window_title="Axis Settings",
config=self._config_dict["axis"],
)
dialog.exec()
def show_curve_settings(self):
dialog = SettingsDialog(
self,
settings_widget=CurveSettings(),
window_title="Curve Settings",
config=self.waveform._curves_data,
)
dialog.resize(800, 600)
dialog.exec()
def show_fit_summary_dialog(self):
dialog = FitSummaryWidget(target_widget=self)
dialog.resize(800, 600)
dialog.exec()
###################################
# User Access Methods from Waveform
###################################
@property
def curves(self) -> list[BECCurve]:
"""
Get the curves of the plot widget as a list
Returns:
list: List of curves.
"""
return self.waveform._curves
@curves.setter
def curves(self, value: list[BECCurve]):
self.waveform._curves = value
def get_curve(self, identifier) -> BECCurve:
"""
Get the curve by its index or ID.
Args:
identifier(int|str): Identifier of the curve. Can be either an integer (index) or a string (curve_id).
Returns:
BECCurve: The curve object.
"""
return self.waveform.get_curve(identifier)
def set_colormap(self, colormap: str):
"""
Set the colormap of the plot widget.
Args:
colormap(str, optional): Scale the colors of curves to colormap. If None, use the default color palette.
"""
self.waveform.set_colormap(colormap)
@Slot(str, str) # Slot for x_name, x_entry
@SafeSlot(str, popup_error=True) # Slot for x_name and
def set_x(self, x_name: str, x_entry: str | None = None):
"""
Change the x axis of the plot widget.
Args:
x_name(str): Name of the x signal.
- "best_effort": Use the best effort signal.
- "timestamp": Use the timestamp signal.
- "index": Use the index signal.
- Custom signal name of device from BEC.
x_entry(str): Entry of the x signal.
"""
self.waveform.set_x(x_name, x_entry)
@Slot(str) # Slot for y_name
@SafeSlot(popup_error=True)
def plot(
self,
arg1: list | np.ndarray | str | None = None,
x: list | np.ndarray | None = None,
y: 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,
dap: str | None = None, # TODO add dap custom curve wrapper
**kwargs,
) -> BECCurve:
"""
Plot a curve to the plot widget.
Args:
arg1(list | np.ndarray | str | None): First argument which can be x data(list | np.ndarray), y data(list | np.ndarray), or y_name(str).
x(list | np.ndarray): Custom x data to plot.
y(list | np.ndarray): Custom y 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.
dap(str): The dap model to use for the curve. If not specified, none will be added.
Returns:
BECCurve: The curve object.
"""
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="scan_segment")
return self.waveform.plot(
arg1=arg1,
x=x,
y=y,
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,
**kwargs,
)
@Slot(
str, str, str, str, str, str, bool
) # Slot for x_name, y_name, x_entry, y_entry, color, validate_bec
@SafeSlot(str, str, str, popup_error=True)
def add_dap(
self,
x_name: str,
y_name: str,
dap: str,
x_entry: str | None = None,
y_entry: str | None = None,
color: str | None = None,
validate_bec: bool = True,
**kwargs,
) -> BECCurve:
"""
Add LMFIT dap model curve to the plot widget.
Args:
x_name(str): Name of the x signal.
x_entry(str): Entry of the x signal.
y_name(str): Name of the y signal.
y_entry(str): Entry of the y signal.
color(str, optional): Color of the curve. Defaults to None.
dap(str): The dap model to use for the curve.
validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True.
**kwargs: Additional keyword arguments for the curve configuration.
Returns:
BECCurve: The curve object.
"""
if self.clear_curves_on_plot_update is True:
self.waveform.clear_source(source="DAP")
return self.waveform.add_dap(
x_name=x_name,
y_name=y_name,
x_entry=x_entry,
y_entry=y_entry,
color=color,
dap=dap,
validate_bec=validate_bec,
**kwargs,
)
def get_dap_params(self) -> dict:
"""
Get the DAP parameters of all DAP curves.
Returns:
dict: DAP parameters of all DAP curves.
"""
return self.waveform.get_dap_params()
def get_dap_summary(self) -> dict:
"""
Get the DAP summary of all DAP curves.
Returns:
dict: DAP summary of all DAP curves.
"""
return self.waveform.get_dap_summary()
def remove_curve(self, *identifiers):
"""
Remove a curve from the plot widget.
Args:
*identifiers: Identifier of the curve to be removed. Can be either an integer (index) or a string (curve_id).
"""
self.waveform.remove_curve(*identifiers)
def scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
self.waveform.scan_history(scan_index, scan_id)
def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict | pd.DataFrame:
"""
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.
"""
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"
return self.waveform.get_all_data(output)
###################################
# 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.waveform.set(**kwargs)
def set_title(self, title: str):
"""
Set the title of the plot widget.
Args:
title(str): Title of the plot.
"""
self.waveform.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.waveform.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.waveform.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.waveform.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.waveform.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.waveform.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.waveform.set_y_lim(y_lim)
def set_legend_label_size(self, legend_label_size: int):
"""
Set the size of the legend labels of the plot widget.
Args:
legend_label_size(int): Size of the legend labels.
"""
self.waveform.set_legend_label_size(legend_label_size)
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.waveform.set_auto_range(enabled, axis)
def toggle_roi(self, checked: bool):
"""Toggle the linear region selector.
Args:
checked(bool): If True, enable the linear region selector.
"""
self.waveform.toggle_roi(checked)
if self.toolbar.widgets["roi_select"].action.isChecked() != checked:
self.toolbar.widgets["roi_select"].action.setChecked(checked)
def select_roi(self, region: tuple):
"""
Set the region of interest of the plot widget.
Args:
region(tuple): Region of interest.
"""
self.waveform.select_roi(region)
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.waveform.enable_fps_monitor(enabled)
if self.toolbar.widgets["fps_monitor"].action.isChecked() != enabled:
self.toolbar.widgets["fps_monitor"].action.setChecked(enabled)
@SafeSlot()
def _auto_range_from_toolbar(self):
"""
Set the auto range of the plot widget from the toolbar.
"""
self.waveform.set_auto_range(True, "xy")
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.waveform.set_grid(x_grid, y_grid)
def set_outer_axes(self, show: bool):
"""
Set the outer axes visibility of the plot widget.
Args:
show(bool): Visibility of the outer axes.
"""
self.waveform.set_outer_axes(show)
def enable_scatter(self, enabled: bool):
"""
Enable the scatter plot of the plot widget.
Args:
enabled(bool): If True, enable the scatter plot.
"""
self.waveform.enable_scatter(enabled)
def lock_aspect_ratio(self, lock: bool):
"""
Lock the aspect ratio of the plot widget.
Args:
lock(bool): Lock the aspect ratio.
"""
self.waveform.lock_aspect_ratio(lock)
@SafeSlot()
def enable_mouse_rectangle_mode(self):
self.toolbar.widgets["rectangle_mode"].action.setChecked(True)
self.toolbar.widgets["drag_mode"].action.setChecked(False)
self.waveform.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.waveform.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
def export(self):
"""
Show the export dialog for the plot widget.
"""
self.waveform.export()
def export_to_matplotlib(self):
"""
Export the plot widget to Matplotlib.
"""
try:
import matplotlib as mpl
except ImportError:
self.warning_util.show_warning(
title="Matplotlib not installed",
message="Matplotlib is required for this feature.",
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return
self.waveform.export_to_matplotlib()
#######################################
# User Access Methods from BECConnector
######################################
def load_config(self, path: str | None = None, gui: bool = False):
"""
Load the configuration of the widget from YAML.
Args:
path(str): Path to the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to load the configuration file.
"""
self.fig.load_config(path=path, gui=gui)
def save_config(self, path: str | None = None, gui: bool = False):
"""
Save the configuration of the widget to YAML.
Args:
path(str): Path to save the configuration file for non-GUI dialog mode.
gui(bool): If True, use the GUI dialog to save the configuration file.
"""
self.fig.save_config(path=path, gui=gui)
def cleanup(self):
self.fig.cleanup()
return super().cleanup()
def main(): # pragma: no cover
from qtpy.QtWidgets import QApplication
app = QApplication(sys.argv)
widget = BECWaveformWidget()
widget.plot(x_name="samx", y_name="bpm4i")
widget.plot(y_name="bpm3i")
widget.plot(y_name="bpm4a")
widget.plot(y_name="bpm5i")
widget.show()
sys.exit(app.exec_())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,943 @@
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.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.toolbar import MaterialIconAction, SwitchableToolBarAction
from bec_widgets.utils import ConnectionConfig
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.plots_next_gen.image.image_item import ImageItem
from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.image_selection import (
MonitorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.image.toolbar_bundles.processing import (
ImageProcessingToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.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,
):
self._main_image = ImageItem(parent_image=self)
self._color_bar = None
if config is None:
config = ImageConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
# For PropertyManager identification
self.setObjectName("Image")
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.
"""
if self._main_image.config.monitor is not None:
self.disconnect_monitor(self._main_image.config.monitor)
self._main_image.config.monitor = None
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
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

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

View File

@@ -0,0 +1,260 @@
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_next_gen.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",
]
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
super().__init__(config=config, gui_id=gui_id)
pg.ImageItem.__init__(self)
self.parent_image = parent_image
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_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) -> Literal["max", "mean"]:
return self.config.autorange_mode
@autorange_mode.setter
def autorange_mode(self, mode: Literal["max", "mean"]):
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()
################################################################################
# Data Update Logic
def clear(self):
super().clear()
self.raw_data = None
self.buffer = []
self.max_len = 0

View File

@@ -1,43 +1,39 @@
# 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.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots_next_gen.image.image import Image
DOM_XML = """
<ui language='c++'>
<widget class='BECImageWidget' name='bec_image_widget'>
<widget class='Image' name='image'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECImageWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class ImagePlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECImageWidget(parent)
t = Image(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Plots"
return "Plot Widgets Next Gen"
def icon(self):
return designer_material_icon(BECImageWidget.ICON_NAME)
return designer_material_icon(Image.ICON_NAME)
def includeFile(self):
return "bec_image_widget"
return "image"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -49,10 +45,10 @@ class BECImageWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
return self._form_editor is not None
def name(self):
return "BECImageWidget"
return "Image"
def toolTip(self):
return "BECImageWidget"
return "Image"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,150 @@
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from pydantic import BaseModel, Field
from qtpy.QtCore import QObject, Signal
@dataclass
class ImageStats:
"""Container to store stats of an image."""
maximum: float
minimum: float
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(
False, description="Whether to transpose the monitor data before displaying."
)
rotation: int = Field(
0, description="The rotation angle of the monitor data before displaying."
)
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 for processing the image data.
"""
image_processed = Signal(np.ndarray)
def __init__(self, parent=None, config: ProcessingConfig = None):
super().__init__(parent=parent)
if config is None:
config = ProcessingConfig()
self.config = config
self._current_thread = None
def set_config(self, config: ProcessingConfig):
"""
Set the configuration of the processor.
Args:
config(ProcessingConfig): The configuration of the processor.
"""
self.config = config
def FFT(self, data: np.ndarray) -> np.ndarray:
"""
Perform FFT on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.abs(np.fft.fftshift(np.fft.fft2(data)))
def rotation(self, data: np.ndarray, rotate_90: int) -> np.ndarray:
"""
Rotate the data by 90 degrees n times.
Args:
data(np.ndarray): The data to be processed.
rotate_90(int): The number of 90 degree rotations.
Returns:
np.ndarray: The processed data.
"""
return np.rot90(data, k=rotate_90, axes=(0, 1))
def transpose(self, data: np.ndarray) -> np.ndarray:
"""
Transpose the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
return np.transpose(data)
def log(self, data: np.ndarray) -> np.ndarray:
"""
Perform log on the data.
Args:
data(np.ndarray): The data to be processed.
Returns:
np.ndarray: The processed data.
"""
# TODO this is not final solution -> data should stay as int16
data = data.astype(np.float32)
offset = 1e-6
data_offset = data + offset
return np.log10(data_offset)
def update_image_stats(self, data: np.ndarray) -> None:
"""Get the statistics of the image data.
Args:
data(np.ndarray): The image data.
"""
self.config.stats.maximum = np.max(data)
self.config.stats.minimum = np.min(data)
self.config.stats.mean = np.mean(data)
self.config.stats.std = np.std(data)
def process_image(self, data: np.ndarray) -> np.ndarray:
"""Core processing logic without threading overhead."""
if self.config.fft:
data = self.FFT(data)
if self.config.rotation is not None:
data = self.rotation(data, self.config.rotation)
if self.config.transpose:
data = self.transpose(data)
if self.config.log:
data = self.log(data)
self.update_image_stats(data)
return data

View File

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

View File

@@ -0,0 +1,57 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QStyledItemDelegate
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_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(
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

@@ -0,0 +1,79 @@
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_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,793 @@
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.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import MaterialIconAction
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.plots_next_gen.motor_map.settings.motor_map_settings import (
MotorMapSettings,
)
from bec_widgets.widgets.plots_next_gen.motor_map.toolbar_bundles.motor_selection import (
MotorSelectionToolbarBundle,
)
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
logger = bec_logger.logger
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
)
# For PropertyManager identification
self.setObjectName("MotorMap")
# 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(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(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
if motor_x_limit is not None or motor_y_limit is not None:
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, 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 _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_())

View File

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

View File

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

View File

@@ -6,11 +6,9 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots.waveform.bec_waveform_widget_plugin import (
BECWaveformWidgetPlugin,
)
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map_plugin import MotorMapPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECWaveformWidgetPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(MotorMapPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1,130 @@
import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout, QWidget
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
from bec_widgets.utils.widget_io import WidgetIO
class MotorMapSettings(SettingWidget):
"""
A settings widget for the MotorMap widget.
The widget has skip_settings property set to True, which means it should not be saved
in the settings file. It is used to mirror the properties of the target widget.
"""
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("MotorMapSettings")
current_path = os.path.dirname(__file__)
form = UILoader().load_ui(os.path.join(current_path, "motor_map_settings.ui"), self)
self.target_widget = target_widget
self.popup = popup
# # Scroll area
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setWidget(form)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
self.ui = form
self.ui_widget_list = [
self.ui.max_points,
self.ui.num_dim_points,
self.ui.precision,
self.ui.scatter_size,
self.ui.background_value,
]
if self.target_widget is not None and self.popup is False:
self.connect_all_signals()
self.target_widget.property_changed.connect(self.update_property)
self.fetch_all_properties()
def connect_all_signals(self):
for widget in self.ui_widget_list:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
self.ui.color_scatter.color_selected.connect(
lambda color: self.target_widget.setProperty("color_scatter", color)
)
@SafeSlot()
def set_property(self, widget: QWidget, value):
"""
Set property of the target widget based on the widget that emitted the signal.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
widget(QWidget): The widget that emitted the signal.
value(): The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
property_name = widget.objectName()
setattr(self.target_widget, property_name, value)
except RuntimeError:
return
if property_name == "color_scatter":
# Update the color scatter button
self.ui.color_scatter.set_color(value)
@SafeSlot()
def update_property(self, property_name: str, value):
"""
Update the value of the widget based on the property name and value.
The name of the property has to be the same as the objectName of the widget
and compatible with WidgetIO.
Args:
property_name(str): The name of the property to update.
value: The value to set the property to.
"""
try: # to avoid crashing when the widget is not found in Designer
widget_to_set = self.ui.findChild(QWidget, property_name)
except RuntimeError:
return
if widget_to_set is None:
return
if widget_to_set is self.ui.color_scatter:
# Update the color scatter button
self.ui.color_scatter.set_color(value)
return
# Block signals to avoid triggering set_property again
was_blocked = widget_to_set.blockSignals(True)
WidgetIO.set_value(widget_to_set, value)
widget_to_set.blockSignals(was_blocked)
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
for widget in self.ui_widget_list:
property_name = widget.objectName()
value = getattr(self.target_widget, property_name)
WidgetIO.set_value(widget, value)
self.ui.color_scatter.set_color(self.target_widget.color)
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
for widget in self.ui_widget_list:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)
setattr(self.target_widget, property_name, value)
self.target_widget.color_scatter = self.ui.color_scatter.get_color()

View File

@@ -0,0 +1,120 @@
<?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>235</width>
<height>228</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>228</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>228</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="max_point_label">
<property name="text">
<string>Max Points</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QSpinBox" name="max_points">
<property name="maximum">
<number>10000</number>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="trace_label">
<property name="text">
<string>Trace Dim</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QSpinBox" name="num_dim_points">
<property name="maximum">
<number>1000</number>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="precision_label">
<property name="text">
<string>Precision</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="precision">
<property name="maximum">
<number>15</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="scatter_size_label">
<property name="text">
<string>Scatter Size</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="scatter_size">
<property name="maximum">
<number>20</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="background_label">
<property name="text">
<string>Background Intensity</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="background_value">
<property name="maximum">
<number>100</number>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="color_label">
<property name="text">
<string>Color</string>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="ColorButton" name="color_scatter"/>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>ColorButton</class>
<extends>QWidget</extends>
<header>color_button</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>

View File

@@ -0,0 +1,60 @@
from bec_lib.device import ReadoutPriority
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QStyledItemDelegate
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_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 MotorSelectionToolbarBundle(ToolbarBundle):
"""
A bundle of actions for a toolbar that selects motors.
"""
def __init__(self, bundle_id="motor_selection", target_widget=None, **kwargs):
super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
self.target_widget = target_widget
# Motor X
self.motor_x = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_x.addItem("", None)
self.motor_x.setCurrentText("")
self.motor_x.setToolTip("Select Motor X")
self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x))
# Motor X
self.motor_y = DeviceComboBox(device_filter=[BECDeviceFilter.POSITIONER])
self.motor_y.addItem("", None)
self.motor_y.setCurrentText("")
self.motor_y.setToolTip("Select Motor Y")
self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y))
self.add_action("motor_x", WidgetAction(widget=self.motor_x, adjust_size=False))
self.add_action("motor_y", WidgetAction(widget=self.motor_y, adjust_size=False))
# Connect slots, a device will be connected upon change of any combobox
self.motor_x.currentTextChanged.connect(lambda: self.connect_motors())
self.motor_y.currentTextChanged.connect(lambda: self.connect_motors())
@SafeSlot()
def connect_motors(self):
motor_x = self.motor_x.currentText()
motor_y = self.motor_y.currentText()
if motor_x != "" and motor_y != "":
if (
motor_x != self.target_widget.config.x_motor.name
or motor_y != self.target_widget.config.y_motor.name
):
self.target_widget.map(motor_x, motor_y)

View File

@@ -95,6 +95,7 @@ class PlotBase(BECWidget, QWidget):
self.entry_validator = EntryValidator(self.dev)
# Base widgets elements
self._popups = popups
self._ui_mode = UIMode.POPUP if popups else UIMode.SIDE
self.axis_settings_dialog = None
self.plot_widget = pg.GraphicsLayoutWidget(parent=self)
@@ -111,6 +112,8 @@ class PlotBase(BECWidget, QWidget):
self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight)
self._user_x_label = ""
self._x_label_suffix = ""
self._user_y_label = ""
self._y_label_suffix = ""
self._init_ui()
@@ -218,15 +221,28 @@ class PlotBase(BECWidget, QWidget):
self.axis_settings_dialog = None
self.toolbar.widgets["axis"].action.setChecked(False)
def reset_legend(self):
"""In the case that the legend is not visible, reset it to be visible to top left corner"""
self.plot_item.legend.autoAnchor(50)
################################################################################
# Toggle UI Elements
################################################################################
@property
def ui_mode(self) -> UIMode:
"""
Get the UI mode.
"""
return self._ui_mode
@ui_mode.setter
def ui_mode(self, mode: UIMode):
"""
Set the UI mode.
Args:
mode(UIMode): The UI mode to set.
"""
if not isinstance(mode, UIMode):
raise ValueError("ui_mode must be an instance of UIMode")
self._ui_mode = mode
@@ -252,10 +268,19 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(bool, doc="Enable popups setting dialogs for the plot widget.")
def enable_popups(self):
"""
Enable popups setting dialogs for the plot widget.
"""
return self.ui_mode == UIMode.POPUP
@enable_popups.setter
def enable_popups(self, value: bool):
"""
Set the popups setting dialogs for the plot widget.
Args:
value(bool): The value to set.
"""
if value:
self.ui_mode = UIMode.POPUP
else:
@@ -264,10 +289,19 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(bool, doc="Show Side Panel")
def enable_side_panel(self) -> bool:
"""
Show Side Panel
"""
return self.ui_mode == UIMode.SIDE
@enable_side_panel.setter
def enable_side_panel(self, value: bool):
"""
Show Side Panel
Args:
value(bool): The value to set.
"""
if value:
self.ui_mode = UIMode.SIDE
else:
@@ -276,33 +310,36 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(bool, doc="Show Toolbar")
def enable_toolbar(self) -> bool:
"""
Show Toolbar.
"""
return self.toolbar.isVisible()
@enable_toolbar.setter
def enable_toolbar(self, value: bool):
if value:
# Disable popup mode
if self._popups:
# Directly update the internal flag to avoid recursion
self._popups = False
# Hide the popup bundle if it exists and close any open dialogs
if self.popup_bundle is not None:
for action in self.toolbar.bundles["popup_bundle"].actions:
action.setVisible(False)
if self.axis_settings_dialog is not None and self.axis_settings_dialog.isVisible():
self.axis_settings_dialog.close()
self.side_panel.show()
# Add side menus if not already added
self.add_side_menus()
else:
self.side_panel.hide()
"""
Show Toolbar.
Args:
value(bool): The value to set.
"""
self.toolbar.setVisible(value)
@SafeProperty(bool, doc="Enable the FPS monitor.")
def enable_fps_monitor(self) -> bool:
"""
Enable the FPS monitor.
"""
return self.fps_label.isVisible()
@enable_fps_monitor.setter
def enable_fps_monitor(self, value: bool):
"""
Enable the FPS monitor.
Args:
value(bool): The value to set.
"""
if value and self.fps_monitor is None:
self.hook_fps_monitor()
elif not value and self.fps_monitor is not None:
@@ -341,6 +378,14 @@ class PlotBase(BECWidget, QWidget):
**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
"""
property_map = {
@@ -369,19 +414,37 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(str, doc="The title of the axes.")
def title(self) -> str:
"""
Set title of the plot.
"""
return self.plot_item.titleLabel.text
@title.setter
def title(self, value: str):
"""
Set title of the plot.
Args:
value(str): The title to set.
"""
self.plot_item.setTitle(value)
self.property_changed.emit("title", value)
@SafeProperty(str, doc="The text of the x label")
def x_label(self) -> str:
"""
The set label for the x-axis.
"""
return self._user_x_label
@x_label.setter
def x_label(self, value: str):
"""
The set label for the x-axis.
Args:
value(str): The label to set.
"""
self._user_x_label = value
self._apply_x_label()
self.property_changed.emit("x_label", self._user_x_label)
@@ -416,17 +479,57 @@ class PlotBase(BECWidget, QWidget):
the combined label. Called whenever user label or suffix changes.
"""
final_label = self.x_label_combined
self.plot_item.setLabel("bottom", text=final_label)
if self.plot_item.getAxis("bottom").isVisible():
self.plot_item.setLabel("bottom", text=final_label)
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
return self.plot_item.getAxis("left").labelText
"""
The set label for the y-axis.
"""
return self._user_y_label
@y_label.setter
def y_label(self, value: str):
self.plot_item.setLabel("left", text=value)
"""
The set label for the y-axis.
Args:
value(str): The label to set.
"""
self._user_y_label = value
self._apply_y_label()
self.property_changed.emit("y_label", value)
@property
def y_label_suffix(self) -> str:
"""
A read-only suffix automatically appended to the y label.
"""
return self._y_label_suffix
def set_y_label_suffix(self, suffix: str):
"""
Public method to update the y label suffix.
"""
self._y_label_suffix = suffix
self._apply_y_label()
@property
def y_label_combined(self) -> str:
"""
The final y label shown on the axis = user portion + suffix.
"""
return self._user_y_label + self._y_label_suffix
def _apply_y_label(self):
"""
Actually updates the pyqtgraph y axis label text to
the combined y label. Called whenever y label or suffix changes.
"""
final_label = self.y_label_combined
if self.plot_item.getAxis("bottom").isVisible():
self.plot_item.setLabel("left", text=final_label)
def _tuple_to_qpointf(self, tuple: tuple | list):
"""
Helper function to convert a tuple to a QPointF.
@@ -452,37 +555,74 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty("QPointF")
def x_limits(self) -> QPointF:
"""
Get the x limits of the plot.
"""
current_lim = self.plot_item.vb.viewRange()[0]
return QPointF(current_lim[0], current_lim[1])
@x_limits.setter
def x_limits(self, value):
"""
Set the x limits of the plot.
Args:
value(QPointF|tuple|list): The x limits to set.
"""
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setXRange(value.x(), value.y(), padding=0)
@property
def x_lim(self) -> tuple:
"""
Get the x limits of the plot.
"""
return (self.x_limits.x(), self.x_limits.y())
@x_lim.setter
def x_lim(self, value):
"""
Set the x limits of the plot.
Args:
value(tuple): The x limits to set.
"""
self.x_limits = value
@property
def x_min(self) -> float:
"""
Get the minimum x limit of the plot.
"""
return self.x_limits.x()
@x_min.setter
def x_min(self, value: float):
"""
Set the minimum x limit of the plot.
Args:
value(float): The minimum x limit to set.
"""
self.x_limits = (value, self.x_lim[1])
@property
def x_max(self) -> float:
"""
Get the maximum x limit of the plot.
"""
return self.x_limits.y()
@x_max.setter
def x_max(self, value: float):
"""
Set the maximum x limit of the plot.
Args:
value(float): The maximum x limit to set.
"""
self.x_limits = (self.x_lim[0], value)
################################################################################
@@ -491,121 +631,243 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty("QPointF")
def y_limits(self) -> QPointF:
"""
Get the y limits of the plot.
"""
current_lim = self.plot_item.vb.viewRange()[1]
return QPointF(current_lim[0], current_lim[1])
@y_limits.setter
def y_limits(self, value):
"""
Set the y limits of the plot.
Args:
value(QPointF|tuple|list): The y limits to set.
"""
if isinstance(value, (tuple, list)):
value = self._tuple_to_qpointf(value)
self.plot_item.vb.setYRange(value.x(), value.y(), padding=0)
@property
def y_lim(self) -> tuple:
"""
Get the y limits of the plot.
"""
return (self.y_limits.x(), self.y_limits.y())
@y_lim.setter
def y_lim(self, value):
"""
Set the y limits of the plot.
Args:
value(tuple): The y limits to set.
"""
self.y_limits = value
@property
def y_min(self) -> float:
"""
Get the minimum y limit of the plot.
"""
return self.y_limits.x()
@y_min.setter
def y_min(self, value: float):
"""
Set the minimum y limit of the plot.
Args:
value(float): The minimum y limit to set.
"""
self.y_limits = (value, self.y_lim[1])
@property
def y_max(self) -> float:
"""
Get the maximum y limit of the plot.
"""
return self.y_limits.y()
@y_max.setter
def y_max(self, value: float):
"""
Set the maximum y limit of the plot.
Args:
value(float): The maximum y limit to set.
"""
self.y_limits = (self.y_lim[0], value)
@SafeProperty(bool, doc="Show grid on the x-axis.")
def x_grid(self) -> bool:
"""
Show grid on the x-axis.
"""
return self.plot_item.ctrl.xGridCheck.isChecked()
@x_grid.setter
def x_grid(self, value: bool):
"""
Show grid on the x-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.showGrid(x=value)
self.property_changed.emit("x_grid", value)
@SafeProperty(bool, doc="Show grid on the y-axis.")
def y_grid(self) -> bool:
"""
Show grid on the y-axis.
"""
return self.plot_item.ctrl.yGridCheck.isChecked()
@y_grid.setter
def y_grid(self, value: bool):
"""
Show grid on the y-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.showGrid(y=value)
self.property_changed.emit("y_grid", value)
@SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.")
def x_log(self) -> bool:
"""
Set X-axis to log scale if True, linear if False.
"""
return bool(self.plot_item.vb.state.get("logMode", [False, False])[0])
@x_log.setter
def x_log(self, value: bool):
"""
Set X-axis to log scale if True, linear if False.
Args:
value(bool): The value to set.
"""
self.plot_item.setLogMode(x=value)
self.property_changed.emit("x_log", value)
@SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.")
def y_log(self) -> bool:
"""
Set Y-axis to log scale if True, linear if False.
"""
return bool(self.plot_item.vb.state.get("logMode", [False, False])[1])
@y_log.setter
def y_log(self, value: bool):
"""
Set Y-axis to log scale if True, linear if False.
Args:
value(bool): The value to set.
"""
self.plot_item.setLogMode(y=value)
self.property_changed.emit("y_log", value)
@SafeProperty(bool, doc="Show the outer axes of the plot widget.")
def outer_axes(self) -> bool:
"""
Show the outer axes of the plot widget.
"""
return self.plot_item.getAxis("top").isVisible()
@outer_axes.setter
def outer_axes(self, value: bool):
"""
Show the outer axes of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.showAxis("top", value)
self.plot_item.showAxis("right", value)
self.property_changed.emit("outer_axes", value)
@SafeProperty(bool, doc="Show inner axes of the plot widget.")
def inner_axes(self) -> bool:
"""
Show inner axes of the plot widget.
"""
return self.plot_item.getAxis("bottom").isVisible()
@inner_axes.setter
def inner_axes(self, value: bool):
"""
Show inner axes of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.showAxis("bottom", value)
self.plot_item.showAxis("left", value)
self._apply_x_label()
self._apply_y_label()
self.property_changed.emit("inner_axes", value)
@SafeProperty(bool, doc="Lock aspect ratio of the plot widget.")
def lock_aspect_ratio(self) -> bool:
"""
Lock aspect ratio of the plot widget.
"""
return bool(self.plot_item.vb.getState()["aspectLocked"])
@lock_aspect_ratio.setter
def lock_aspect_ratio(self, value: bool):
"""
Lock aspect ratio of the plot widget.
Args:
value(bool): The value to set.
"""
self.plot_item.setAspectLocked(value)
@SafeProperty(bool, doc="Set auto range for the x-axis.")
def auto_range_x(self) -> bool:
"""
Set auto range for the x-axis.
"""
return bool(self.plot_item.vb.getState()["autoRange"][0])
@auto_range_x.setter
def auto_range_x(self, value: bool):
"""
Set auto range for the x-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.enableAutoRange(x=value)
@SafeProperty(bool, doc="Set auto range for the y-axis.")
def auto_range_y(self) -> bool:
"""
Set auto range for the y-axis.
"""
return bool(self.plot_item.vb.getState()["autoRange"][1])
@auto_range_y.setter
def auto_range_y(self, value: bool):
"""
Set auto range for the y-axis.
Args:
value(bool): The value to set.
"""
self.plot_item.enableAutoRange(y=value)
@SafeProperty(int, doc="The font size of the legend font.")
def legend_label_size(self) -> int:
"""
The font size of the legend font.
"""
if not self.plot_item.legend:
return
scale = self.plot_item.legend.scale() * 9
@@ -613,6 +875,12 @@ class PlotBase(BECWidget, QWidget):
@legend_label_size.setter
def legend_label_size(self, value: int):
"""
The font size of the legend font.
Args:
value(int): The font size to set.
"""
if not self.plot_item.legend:
return
scale = (
@@ -699,12 +967,16 @@ class PlotBase(BECWidget, QWidget):
def cleanup(self):
self.unhook_crosshair()
self.unhook_fps_monitor(delete_label=True)
if self.axis_settings_dialog is not None:
self.axis_settings_dialog.close()
self.axis_settings_dialog = None
self.cleanup_pyqtgraph()
self.rpc_register.remove_rpc(self)
super().cleanup()
def cleanup_pyqtgraph(self):
def cleanup_pyqtgraph(self, item: pg.PlotItem | None = None):
"""Cleanup pyqtgraph items."""
item = self.plot_item
if item is None:
item = self.plot_item
item.vb.menu.close()
item.vb.menu.deleteLater()
item.ctrlMenu.close()

View File

@@ -6,11 +6,11 @@ def main(): # pragma: no cover
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots.motor_map.bec_motor_map_widget_plugin import (
BECMotorMapWidgetPlugin,
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform_plugin import (
ScatterWaveformPlugin,
)
QPyDesignerCustomWidgetCollection.addCustomWidget(BECMotorMapWidgetPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(ScatterWaveformPlugin())
if __name__ == "__main__": # pragma: no cover

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy as np
import pyqtgraph as pg
from bec_lib import bec_logger
from pydantic import BaseModel, Field, ValidationError, 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_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
logger = bec_logger.logger
# noinspection PyDataclass
class ScatterDeviceSignal(BaseModel):
"""The configuration of a signal in the scatter waveform widget."""
name: str
entry: str
model_config: dict = {"validate_assignment": True}
# noinspection PyDataclass
class ScatterCurveConfig(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 = Field("#808080", description="The color of the curve.")
symbol: str | None = Field("o", description="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"] = Field(
"solid", description="The style of the pen of the curve."
)
color_map: str | None = Field(
"magma", description="The color palette of the figure widget.", validate_default=True
)
x_device: ScatterDeviceSignal | None = Field(
None, description="The x device signal of the scatter waveform."
)
y_device: ScatterDeviceSignal | None = Field(
None, description="The y device signal of the scatter waveform."
)
z_device: ScatterDeviceSignal | None = Field(
None, description="The z device signal of the scatter waveform."
)
model_config: dict = {"validate_assignment": True}
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
class ScatterCurve(BECConnector, pg.PlotDataItem):
"""Scatter curve item for the scatter waveform widget."""
USER_ACCESS = ["color_map"]
def __init__(
self,
parent_item: ScatterWaveform,
name: str | None = None,
config: ScatterCurveConfig | None = None,
gui_id: str | None = None,
**kwargs,
):
if config is None:
config = ScatterCurveConfig(
label=name,
widget_class=self.__class__.__name__,
parent_id=parent_item.config.gui_id,
)
self.config = config
else:
self.config = config
name = config.label
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.data_z = None # color scaling needs to be cashed for changing colormap
self.apply_config()
def apply_config(self, config: dict | ScatterCurveConfig | None = None, **kwargs) -> None:
"""
Apply the configuration to the curve.
Args:
config(dict|ScatterCurveConfig, optional): The configuration to apply.
"""
if config is not None:
if isinstance(config, dict):
config = ScatterCurveConfig(**config)
self.config = config
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
@property
def color_map(self) -> str:
"""The color map of the scatter curve."""
return self.config.color_map
@color_map.setter
def color_map(self, value: str):
"""
Set the color map of the scatter curve.
Args:
value(str): The color map to set.
"""
try:
if value != self.config.color_map:
self.config.color_map = value
self.refresh_color_map(value)
except ValidationError:
return
def set_data(
self,
x: list[float] | np.ndarray,
y: list[float] | np.ndarray,
z: list[float] | np.ndarray,
color_map: str | None = None,
):
"""
Set the data of the scatter curve.
Args:
x (list[float] | np.ndarray): The x data of the scatter curve.
y (list[float] | np.ndarray): The y data of the scatter curve.
z (list[float] | np.ndarray): The z data of the scatter curve.
color_map (str | None): The color map of the scatter curve.
"""
if color_map is None:
color_map = self.config.color_map
self.data_z = z
color_z = self._make_z_gradient(z, color_map)
try:
self.setData(x=x, y=y, symbolBrush=color_z)
except TypeError:
logger.error("Error in setData, one of the data arrays is None")
def _make_z_gradient(self, data_z: list | np.ndarray, colormap: str) -> list | None:
"""
Make a gradient color for the z values.
Args:
data_z(list|np.ndarray): Z values.
colormap(str): Colormap for the gradient color.
Returns:
list: List of colors for the z values.
"""
# Normalize z_values for color mapping
z_min, z_max = np.min(data_z), np.max(data_z)
if z_max != z_min: # Ensure that there is a range in the z values
z_values_norm = (data_z - z_min) / (z_max - z_min)
colormap = pg.colormap.get(colormap) # using colormap from global settings
colors = [colormap.map(z, mode="qcolor") for z in z_values_norm]
return colors
else:
return None
def refresh_color_map(self, color_map: str):
"""
Refresh the color map of the scatter curve.
Args:
color_map(str): The color map to use.
"""
x_data, y_data = self.getData()
if x_data is None or y_data is None:
return
if self.data_z is not None:
self.set_data(x_data, y_data, self.data_z, color_map)

View File

@@ -0,0 +1,522 @@
from __future__ import annotations
import json
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 QTimer, Signal
from qtpy.QtWidgets import QHBoxLayout, QMainWindow, QWidget
from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.qt_utils.toolbar import MaterialIconAction
from bec_widgets.utils import Colors, ConnectionConfig
from bec_widgets.utils.colors import set_theme
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_curve import (
ScatterCurve,
ScatterCurveConfig,
ScatterDeviceSignal,
)
from bec_widgets.widgets.plots_next_gen.scatter_waveform.settings.scatter_curve_setting import (
ScatterCurveSettings,
)
logger = bec_logger.logger
# noinspection PyDataclass
class ScatterWaveformConfig(ConnectionConfig):
color_map: str | None = Field(
"magma",
description="The color map of the z scaling of scatter waveform.",
validate_default=True,
)
model_config: dict = {"validate_assignment": True}
_validate_color_palette = field_validator("color_map")(Colors.validate_color_map)
class ScatterWaveform(PlotBase):
PLUGIN = True
RPC = True
ICON_NAME = "scatter_plot"
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",
# Scatter Waveform Specific RPC Access
"main_curve",
"color_map",
"color_map.setter",
"plot",
"update_with_scan_history",
"clear_all",
]
sync_signal_update = Signal()
new_scan = Signal()
new_scan_id = Signal(str)
scatter_waveform_property_changed = Signal()
def __init__(
self,
parent: QWidget | None = None,
config: ScatterWaveformConfig | None = None,
client=None,
gui_id: str | None = None,
popups: bool = True,
**kwargs,
):
if config is None:
config = ScatterWaveformConfig(widget_class=self.__class__.__name__)
super().__init__(
parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
)
self._main_curve = ScatterCurve(parent_item=self)
# For PropertyManager identification
self.setObjectName("ScatterWaveform")
# Specific GUI elements
self.scatter_dialog = None
# Scan Data
self.old_scan_id = None
self.scan_id = None
self.scan_item = None
# Scan status update loop
self.bec_dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
self.bec_dispatcher.connect_slot(self.on_scan_progress, MessageEndpoints.scan_progress())
# Curve update loop
self.proxy_update_sync = pg.SignalProxy(
self.sync_signal_update, rateLimit=25, slot=self.update_sync_curves
)
self._init_scatter_curve_settings()
self.update_with_scan_history(-1)
################################################################################
# Widget Specific GUI interactions
################################################################################
def _init_scatter_curve_settings(self):
"""
Initialize the scatter curve settings menu.
"""
scatter_curve_settings = ScatterCurveSettings(target_widget=self, popup=False)
self.side_panel.add_menu(
action_id="scatter_curve",
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
widget=scatter_curve_settings,
title="Scatter Curve Settings",
)
def add_popups(self):
"""
Add popups to the ScatterWaveform widget.
"""
super().add_popups()
scatter_curve_setting_action = MaterialIconAction(
icon_name="scatter_plot",
tooltip="Show Scatter Curve Settings",
checkable=True,
parent=self,
)
self.toolbar.add_action_to_bundle(
bundle_id="popup_bundle",
action_id="scatter_waveform_settings",
action=scatter_curve_setting_action,
target_widget=self,
)
self.toolbar.widgets["scatter_waveform_settings"].action.triggered.connect(
self.show_scatter_curve_settings
)
def show_scatter_curve_settings(self):
"""
Show the scatter curve settings dialog.
"""
scatter_settings_action = self.toolbar.widgets["scatter_waveform_settings"].action
if self.scatter_dialog is None or not self.scatter_dialog.isVisible():
scatter_settings = ScatterCurveSettings(target_widget=self, popup=True)
self.scatter_dialog = SettingsDialog(
self,
settings_widget=scatter_settings,
window_title="Scatter Curve Settings",
modal=False,
)
self.scatter_dialog.resize(620, 200)
# When the dialog is closed, update the toolbar icon and clear the reference
self.scatter_dialog.finished.connect(self._scatter_dialog_closed)
self.scatter_dialog.show()
scatter_settings_action.setChecked(True)
else:
# If already open, bring it to the front
self.scatter_dialog.raise_()
self.scatter_dialog.activateWindow()
scatter_settings_action.setChecked(True) # keep it toggled
def _scatter_dialog_closed(self):
"""
Slot for when the scatter curve settings dialog is closed.
"""
self.scatter_dialog = None
self.toolbar.widgets["scatter_waveform_settings"].action.setChecked(False)
################################################################################
# Widget Specific Properties
################################################################################
@property
def main_curve(self) -> ScatterCurve:
"""The main scatter curve item."""
return self._main_curve
@SafeProperty(str)
def color_map(self) -> str:
"""The color map of the scatter waveform."""
return self.config.color_map
@color_map.setter
def color_map(self, value: str):
"""
Set the color map of the scatter waveform.
Args:
value(str): The color map to set.
"""
try:
self.config.color_map = value
self.main_curve.color_map = value
self.scatter_waveform_property_changed.emit()
except ValidationError:
return
@SafeProperty(str, designable=False, popup_error=True)
def curve_json(self) -> str:
"""
Get the curve configuration as a JSON string.
"""
return json.dumps(self.main_curve.config.model_dump(), indent=2)
@curve_json.setter
def curve_json(self, value: str):
"""
Set the curve configuration from a JSON string.
Args:
value(str): The JSON string to set the curve configuration from.
"""
try:
config = ScatterCurveConfig(**json.loads(value))
self._add_main_scatter_curve(config)
except json.JSONDecodeError as e:
logger.error(f"Failed to decode JSON: {e}")
################################################################################
# High Level methods for API
################################################################################
@SafeSlot(popup_error=True)
def plot(
self,
x_name: str,
y_name: str,
z_name: str,
x_entry: None | str = None,
y_entry: None | str = None,
z_entry: None | str = None,
color_map: str | None = "magma",
label: str | None = None,
validate_bec: bool = True,
) -> ScatterCurve:
"""
Plot the data from the device signals.
Args:
x_name (str): The name of the x device signal.
y_name (str): The name of the y device signal.
z_name (str): The name of the z device signal.
x_entry (None | str): The x entry of the device signal.
y_entry (None | str): The y entry of the device signal.
z_entry (None | str): The z entry of the device signal.
color_map (str | None): The color map of the scatter waveform.
label (str | None): The label of the curve.
validate_bec (bool): Whether to validate the device signals with current BEC instance.
Returns:
ScatterCurve: The scatter curve object.
"""
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)
z_entry = self.entry_validator.validate_signal(z_name, z_entry)
if color_map is not None:
try:
self.config.color_map = color_map
except ValidationError:
raise ValueError(
f"Invalid color map '{color_map}'. Using previously defined color map '{self.config.color_map}'."
)
if label is None:
label = f"{z_name}-{z_entry}"
config = ScatterCurveConfig(
parent_id=self.gui_id,
label=label,
color_map=color_map,
x_device=ScatterDeviceSignal(name=x_name, entry=x_entry),
y_device=ScatterDeviceSignal(name=y_name, entry=y_entry),
z_device=ScatterDeviceSignal(name=z_name, entry=z_entry),
)
# Add Curve
self._add_main_scatter_curve(config)
self.scatter_waveform_property_changed.emit()
return self._main_curve
def _add_main_scatter_curve(self, config: ScatterCurveConfig):
"""
Add the main scatter curve to the plot.
Args:
config(ScatterCurveConfig): The configuration of the scatter curve.
"""
# Apply suffix for axes
self.set_x_label_suffix(f"[{config.x_device.name}-{config.x_device.name}]")
self.set_y_label_suffix(f"[{config.y_device.name}-{config.y_device.name}]")
# To have only one main curve
if self._main_curve is not None:
self.plot_item.removeItem(self._main_curve)
self._main_curve = None
self._main_curve = ScatterCurve(
parent_item=self, config=config, gui_id=self.gui_id, name=config.label
)
self.plot_item.addItem(self._main_curve)
self.sync_signal_update.emit()
################################################################################
# BEC Update Methods
################################################################################
@SafeSlot(dict, dict)
def on_scan_status(self, msg: dict, meta: dict):
"""
Initial scan status message handler, which is triggered at the begging and end of scan.
Used for triggering the update of the sync and async curves.
Args:
msg(dict): The message content.
meta(dict): The message metadata.
"""
current_scan_id = msg.get("scan_id", None)
if current_scan_id is None:
return
if current_scan_id != self.scan_id:
self.reset()
self.new_scan.emit()
self.new_scan_id.emit(current_scan_id)
self.auto_range_x = True
self.auto_range_y = True
self.old_scan_id = self.scan_id
self.scan_id = current_scan_id
self.scan_item = self.queue.scan_storage.find_scan_by_ID(self.scan_id)
# First trigger to update the scan curves
self.sync_signal_update.emit()
@SafeSlot(dict, dict)
def on_scan_progress(self, msg: dict, meta: dict):
"""
Slot for handling scan progress messages. Used for triggering the update of the sync curves.
Args:
msg(dict): The message content.
meta(dict): The message metadata.
"""
self.sync_signal_update.emit()
status = msg.get("done")
if status:
QTimer.singleShot(100, self.update_sync_curves)
QTimer.singleShot(300, self.update_sync_curves)
@SafeSlot()
def update_sync_curves(self, _=None):
"""
Update the scan curves with the data from the scan segment.
"""
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
data, access_key = self._fetch_scan_data_and_access()
if data == "none":
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none"
try:
x_name = self._main_curve.config.x_device.name
x_entry = self._main_curve.config.x_device.entry
y_name = self._main_curve.config.y_device.name
y_entry = self._main_curve.config.y_device.entry
z_name = self._main_curve.config.z_device.name
z_entry = self._main_curve.config.z_device.entry
except AttributeError:
return
if access_key == "val":
x_data = data.get(x_name, {}).get(x_entry, {}).get(access_key, None)
y_data = data.get(y_name, {}).get(y_entry, {}).get(access_key, None)
z_data = data.get(z_name, {}).get(z_entry, {}).get(access_key, None)
else:
x_data = data.get(x_name, {}).get(x_entry, {}).read().get("value", None)
y_data = data.get(y_name, {}).get(y_entry, {}).read().get("value", None)
z_data = data.get(z_name, {}).get(z_entry, {}).read().get("value", None)
self._main_curve.set_data(x=x_data, y=y_data, z=z_data)
def _fetch_scan_data_and_access(self):
"""
Decide whether the widget is in live or historical mode
and return the appropriate data dict and access key.
Returns:
data_dict (dict): The data structure for the current scan.
access_key (str): Either 'val' (live) or 'value' (history).
"""
if self.scan_item is None:
# Optionally fetch the latest from history if nothing is set
self.update_with_scan_history(-1)
if self.scan_item is None:
logger.info("No scan executed so far; skipping device curves categorisation.")
return "none", "none"
if hasattr(self.scan_item, "live_data"):
# Live scan
return self.scan_item.live_data, "val"
else:
# Historical
scan_devices = self.scan_item.devices
return scan_devices, "value"
@SafeSlot(int)
@SafeSlot(str)
@SafeSlot()
def update_with_scan_history(self, scan_index: int = None, scan_id: str = None):
"""
Update the scan curves with the data from the scan storage.
Provide only one of scan_id or scan_index.
Args:
scan_id(str, optional): ScanID of the scan to be updated. Defaults to None.
scan_index(int, optional): Index of the scan to be updated. Defaults to None.
"""
if scan_index is not None and scan_id is not None:
raise ValueError("Only one of scan_id or scan_index can be provided.")
if scan_index is None and scan_id is None:
logger.warning(f"Neither scan_id or scan_number was provided, fetching the latest scan")
scan_index = -1
if scan_index is not None:
if len(self.client.history) == 0:
logger.info("No scans executed so far. Skipping scan history update.")
return
self.scan_item = self.client.history[scan_index]
metadata = self.scan_item.metadata
self.scan_id = metadata["bec"]["scan_id"]
else:
self.scan_id = scan_id
self.scan_item = self.client.history.get_by_scan_id(scan_id)
self.sync_signal_update.emit()
################################################################################
# Cleanup
################################################################################
@SafeSlot()
def clear_all(self):
"""
Clear all the curves from the plot.
"""
if self.crosshair is not None:
self.crosshair.clear_markers()
self._main_curve.clear()
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.waveform_popup = ScatterWaveform(popups=True)
self.waveform_popup.plot("samx", "samy", "bpm4i")
self.waveform_side = ScatterWaveform(popups=False)
self.waveform_popup.plot("samx", "samy", "bpm3a")
self.layout.addWidget(self.waveform_side)
self.layout.addWidget(self.waveform_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_())

View File

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

View File

@@ -1,43 +1,39 @@
# 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_widget import BECWaveformWidget
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
DOM_XML = """
<ui language='c++'>
<widget class='BECWaveformWidget' name='bec_waveform_widget'>
<widget class='ScatterWaveform' name='scatter_waveform'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class ScatterWaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECWaveformWidget(parent)
t = ScatterWaveform(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "BEC Plots"
return "Plot Widgets Next Gen"
def icon(self):
return designer_material_icon(BECWaveformWidget.ICON_NAME)
return designer_material_icon(ScatterWaveform.ICON_NAME)
def includeFile(self):
return "bec_waveform_widget"
return "scatter_waveform"
def initialize(self, form_editor):
self._form_editor = form_editor
@@ -49,10 +45,10 @@ class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cov
return self._form_editor is not None
def name(self):
return "BECWaveformWidget"
return "ScatterWaveform"
def toolTip(self):
return "BECWaveformWidget"
return "ScatterWaveform"
def whatsThis(self):
return self.toolTip()

View File

@@ -0,0 +1,125 @@
import os
from qtpy.QtWidgets import QFrame, QScrollArea, QVBoxLayout
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.utils import UILoader
class ScatterCurveSettings(SettingWidget):
def __init__(self, parent=None, target_widget=None, popup=False, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# This is a settings widget that depends on the target widget
# and should mirror what is in the target widget.
# Saving settings for this widget could result in recursively setting the target widget.
self.setProperty("skip_settings", True)
self.setObjectName("ScatterCurveSettings")
current_path = os.path.dirname(__file__)
if popup:
form = UILoader().load_ui(
os.path.join(current_path, "scatter_curve_settings_horizontal.ui"), self
)
else:
form = UILoader().load_ui(
os.path.join(current_path, "scatter_curve_settings_vertical.ui"), self
)
self.target_widget = target_widget
self.popup = popup
# # Scroll area
self.scroll_area = QScrollArea(self)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setFrameShape(QFrame.NoFrame)
self.scroll_area.setWidget(form)
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
self.ui = form
self.fetch_all_properties()
self.target_widget.scatter_waveform_property_changed.connect(self.fetch_all_properties)
if popup is False:
self.ui.button_apply.clicked.connect(self.accept_changes)
@SafeSlot()
def fetch_all_properties(self):
"""
Fetch all properties from the target widget and update the settings widget.
"""
if not self.target_widget:
return
# Get properties from the target widget
color_map = getattr(self.target_widget, "color_map", None)
# Default values for device properties
x_name, x_entry = None, None
y_name, y_entry = None, None
z_name, z_entry = None, None
# Safely access device properties
if hasattr(self.target_widget, "main_curve") and self.target_widget.main_curve:
if hasattr(self.target_widget.main_curve, "config"):
config = self.target_widget.main_curve.config
if hasattr(config, "x_device") and config.x_device:
x_name = getattr(config.x_device, "name", None)
x_entry = getattr(config.x_device, "entry", None)
if hasattr(config, "y_device") and config.y_device:
y_name = getattr(config.y_device, "name", None)
y_entry = getattr(config.y_device, "entry", None)
if hasattr(config, "z_device") and config.z_device:
z_name = getattr(config.z_device, "name", None)
z_entry = getattr(config.z_device, "entry", None)
# Apply the properties to the settings widget
if hasattr(self.ui, "color_map"):
self.ui.color_map.colormap = color_map
if hasattr(self.ui, "x_name"):
self.ui.x_name.set_device(x_name)
if hasattr(self.ui, "x_entry") and x_entry is not None:
self.ui.x_entry.setText(x_entry)
if hasattr(self.ui, "y_name"):
self.ui.y_name.set_device(y_name)
if hasattr(self.ui, "y_entry") and y_entry is not None:
self.ui.y_entry.setText(y_entry)
if hasattr(self.ui, "z_name"):
self.ui.z_name.set_device(z_name)
if hasattr(self.ui, "z_entry") and z_entry is not None:
self.ui.z_entry.setText(z_entry)
@SafeSlot()
def accept_changes(self):
"""
Apply all properties from the settings widget to the target widget.
"""
x_name = self.ui.x_name.text()
x_entry = self.ui.x_entry.text()
y_name = self.ui.y_name.text()
y_entry = self.ui.y_entry.text()
z_name = self.ui.z_name.text()
z_entry = self.ui.z_entry.text()
validate_bec = self.ui.validate_bec.checked
color_map = self.ui.color_map.colormap
self.target_widget.plot(
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_map=color_map,
validate_bec=validate_bec,
)

View File

@@ -0,0 +1,195 @@
<?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>604</width>
<height>166</height>
</rect>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout_2">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Validate BEC</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="validate_bec"/>
</item>
<item>
<widget class="BECColorMapWidget" name="color_map"/>
</item>
</layout>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>X Device</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="x_name"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Y Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="y_name"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="y_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Z Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="z_entry"/>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="z_name"/>
</item>
</layout>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceLineEdit</class>
<extends>QLineEdit</extends>
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<signal>textChanged(QString)</signal>
<receiver>x_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>134</x>
<y>95</y>
</hint>
<hint type="destinationlabel">
<x>138</x>
<y>128</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>textChanged(QString)</signal>
<receiver>y_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>351</x>
<y>91</y>
</hint>
<hint type="destinationlabel">
<x>349</x>
<y>121</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>textChanged(QString)</signal>
<receiver>z_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>520</x>
<y>98</y>
</hint>
<hint type="destinationlabel">
<x>522</x>
<y>127</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -0,0 +1,204 @@
<?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>233</width>
<height>427</height>
</rect>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>427</height>
</size>
</property>
<property name="windowTitle">
<string>Form</string>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QPushButton" name="button_apply">
<property name="text">
<string>Apply</string>
</property>
</widget>
</item>
<item>
<widget class="BECColorMapWidget" name="color_map"/>
</item>
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLabel" name="label_7">
<property name="text">
<string>Validate BEC</string>
</property>
</widget>
</item>
<item>
<widget class="ToggleSwitch" name="validate_bec"/>
</item>
</layout>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>X Device</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="x_name"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="x_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_2">
<property name="title">
<string>Y Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="y_name"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_4">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="y_entry"/>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox_3">
<property name="title">
<string>Z Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_3">
<item row="0" column="0">
<widget class="QLabel" name="label_5">
<property name="text">
<string>Name</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="DeviceLineEdit" name="z_name"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_6">
<property name="text">
<string>Signal</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="z_entry"/>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>DeviceLineEdit</class>
<extends>QLineEdit</extends>
<header>device_line_edit</header>
</customwidget>
<customwidget>
<class>ToggleSwitch</class>
<extends>QWidget</extends>
<header>toggle_switch</header>
</customwidget>
<customwidget>
<class>BECColorMapWidget</class>
<extends>QWidget</extends>
<header>bec_color_map_widget</header>
</customwidget>
</customwidgets>
<resources/>
<connections>
<connection>
<sender>x_name</sender>
<signal>textChanged(QString)</signal>
<receiver>x_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>156</x>
<y>123</y>
</hint>
<hint type="destinationlabel">
<x>158</x>
<y>157</y>
</hint>
</hints>
</connection>
<connection>
<sender>y_name</sender>
<signal>textChanged(QString)</signal>
<receiver>y_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>116</x>
<y>229</y>
</hint>
<hint type="destinationlabel">
<x>116</x>
<y>251</y>
</hint>
</hints>
</connection>
<connection>
<sender>z_name</sender>
<signal>textChanged(QString)</signal>
<receiver>z_entry</receiver>
<slot>clear()</slot>
<hints>
<hint type="sourcelabel">
<x>110</x>
<y>326</y>
</hint>
<hint type="destinationlabel">
<x>110</x>
<y>352</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -37,7 +37,6 @@ class AxisSettings(SettingWidget):
self.layout = QVBoxLayout(self)
self.layout.setContentsMargins(0, 0, 0, 0)
self.layout.addWidget(self.scroll_area)
# self.layout.addWidget(self.ui)
self.ui = form
if self.target_widget is not None and self.popup is False:
@@ -50,8 +49,6 @@ class AxisSettings(SettingWidget):
def connect_all_signals(self):
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
@@ -62,6 +59,8 @@ class AxisSettings(SettingWidget):
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.inner_axes,
self.ui.outer_axes,
]:
WidgetIO.connect_widget_change_signal(widget, self.set_property)
@@ -98,6 +97,8 @@ class AxisSettings(SettingWidget):
widget_to_set = self.ui.findChild(QWidget, property_name)
except RuntimeError:
return
if widget_to_set is None:
return
# Block signals to avoid triggering set_property again
was_blocked = widget_to_set.blockSignals(True)
WidgetIO.set_value(widget_to_set, value)
@@ -132,8 +133,6 @@ class AxisSettings(SettingWidget):
"""
for widget in [
self.ui.title,
self.ui.inner_axes,
self.ui.outer_axes,
self.ui.x_label,
self.ui.x_min,
self.ui.x_max,
@@ -144,6 +143,8 @@ class AxisSettings(SettingWidget):
self.ui.y_max,
self.ui.y_log,
self.ui.y_grid,
self.ui.outer_axes,
self.ui.inner_axes,
]:
property_name = widget.objectName()
value = WidgetIO.get_value(widget)

View File

@@ -38,12 +38,6 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
checkable=False,
parent=self.target_widget,
)
aspect_ratio = MaterialIconAction(
icon_name="aspect_ratio",
tooltip="Lock image aspect ratio",
checkable=True,
parent=self.target_widget,
)
self.switch_mouse_action = SwitchableToolBarAction(
actions={"drag_mode": drag, "rectangle_mode": rect},
@@ -56,13 +50,11 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
# Add them to the bundle
self.add_action("switch_mouse", self.switch_mouse_action)
self.add_action("auto_range", auto)
self.add_action("aspect_ratio", aspect_ratio)
# Immediately connect signals
drag.action.toggled.connect(self.enable_mouse_pan_mode)
rect.action.toggled.connect(self.enable_mouse_rectangle_mode)
auto.action.triggered.connect(self.autorange_plot)
aspect_ratio.action.toggled.connect(self.lock_aspect_ratio)
# Give some time to check the state
QTimer.singleShot(10, self.get_viewbox_mode)
@@ -94,6 +86,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
self.actions["switch_mouse"].actions["drag_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.RectMode)
self.mouse_mode = "RectMode"
@SafeSlot(bool)
def enable_mouse_pan_mode(self, checked: bool):
@@ -106,6 +99,7 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
self.actions["switch_mouse"].actions["rectangle_mode"].action.setChecked(not checked)
if self.target_widget and checked:
self.target_widget.plot_item.getViewBox().setMouseMode(pg.ViewBox.PanMode)
self.mouse_mode = "PanMode"
@SafeSlot()
def autorange_plot(self):
@@ -115,8 +109,3 @@ class MouseInteractionToolbarBundle(ToolbarBundle):
if self.target_widget:
self.target_widget.auto_range_x = True
self.target_widget.auto_range_y = True
@SafeSlot(bool)
def lock_aspect_ratio(self, checked: bool):
if self.target_widget:
self.target_widget.lock_aspect_ratio = checked

View File

@@ -1,3 +1,5 @@
import traceback
from pyqtgraph.exporters import MatplotlibExporter
from bec_widgets.qt_utils.error_popups import SafeSlot, WarningPopupUtility
@@ -60,7 +62,7 @@ class PlotExportBundle(ToolbarBundle):
import matplotlib as mpl
MatplotlibExporter(self.target_widget.plot_item).export()
except:
except ModuleNotFoundError:
warning_util = WarningPopupUtility()
warning_util.show_warning(
title="Matplotlib not installed",
@@ -68,3 +70,12 @@ class PlotExportBundle(ToolbarBundle):
detailed_text="Please install matplotlib in your Python environment by using 'pip install matplotlib'.",
)
return
except TypeError:
warning_util = WarningPopupUtility()
error_msg = traceback.format_exc()
warning_util.show_warning(
title="Matplotlib TypeError",
message="Matplotlib exporter could not resolve the plot item.",
detailed_text=error_msg,
)
return

View File

@@ -18,9 +18,14 @@ class ROIBundle(ToolbarBundle):
crosshair = MaterialIconAction(
icon_name="point_scan", tooltip="Show Crosshair", checkable=True
)
reset_legend = MaterialIconAction(
icon_name="restart_alt", tooltip="Reset the position of legend.", checkable=False
)
# Add them to the bundle
self.add_action("crosshair", crosshair)
self.add_action("reset_legend", reset_legend)
# Immediately connect signals
crosshair.action.toggled.connect(self.target_widget.toggle_crosshair)
reset_legend.action.triggered.connect(self.target_widget.reset_legend)

View File

@@ -0,0 +1,328 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Literal
import numpy as np
import pyqtgraph as pg
from bec_lib 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_next_gen.waveform.waveform import Waveform
logger = bec_logger.logger
# noinspection PyDataclass
class DeviceSignal(BaseModel):
"""The configuration of a signal in the 1D waveform widget."""
name: str
entry: str
dap: str | None = None
dap_oversample: int = 1
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(
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(
"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."
)
model_config: dict = {"validate_assignment": True}
_validate_color = field_validator("color")(Colors.validate_color)
_validate_symbol_color = field_validator("symbol_color")(Colors.validate_color)
class Curve(BECConnector, pg.PlotDataItem):
USER_ACCESS = [
"remove",
"_rpc_id",
"_config_dict",
"set",
"set_data",
"set_color",
"set_color_map_z",
"set_symbol",
"set_symbol_color",
"set_symbol_size",
"set_pen_width",
"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,
**kwargs,
):
if config is None:
config = CurveConfig(label=name, widget_class=self.__class__.__name__)
self.config = config
else:
self.config = config
super().__init__(config=config, gui_id=gui_id)
pg.PlotDataItem.__init__(self, name=name)
self.parent_item = parent_item
self.apply_config()
self.dap_params = None
self.dap_summary = None
if kwargs:
self.set(**kwargs)
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
pen_style_map = {
"solid": QtCore.Qt.SolidLine,
"dash": QtCore.Qt.DashLine,
"dot": QtCore.Qt.DotLine,
"dashdot": QtCore.Qt.DashDotLine,
}
pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine)
pen = pg.mkPen(color=self.config.color, width=self.config.pen_width, style=pen_style)
self.setPen(pen)
if self.config.symbol:
symbol_color = self.config.symbol_color or self.config.color
brush = pg.mkBrush(color=symbol_color)
self.setSymbolBrush(brush)
self.setSymbolSize(self.config.symbol_size)
self.setSymbol(self.config.symbol)
@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.
"""
if self.config.source == "custom":
self.setData(x, y)
else:
raise ValueError(f"Source {self.config.source} do not allow custom data setting.")
def set(self, **kwargs):
"""
Set the properties of the curve.
Args:
**kwargs: Keyword arguments for the properties to be set.
Possible properties:
- color: str
- symbol: str
- symbol_color: str
- symbol_size: int
- pen_width: int
- pen_style: Literal["solid", "dash", "dot", "dashdot"]
"""
# Mapping of keywords to setter methods
method_map = {
"color": self.set_color,
"color_map_z": self.set_color_map_z,
"symbol": self.set_symbol,
"symbol_color": self.set_symbol_color,
"symbol_size": self.set_symbol_size,
"pen_width": self.set_pen_width,
"pen_style": self.set_pen_style,
}
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_color(self, color: str, symbol_color: str | None = None):
"""
Change the color of the curve.
Args:
color(str): Color of the curve.
symbol_color(str, optional): Color of the symbol. Defaults to None.
"""
self.config.color = color
self.config.symbol_color = symbol_color or color
self.apply_config()
def set_symbol(self, symbol: str):
"""
Change the symbol of the curve.
Args:
symbol(str): Symbol of the curve.
"""
self.config.symbol = symbol
self.setSymbol(symbol)
self.updateItems()
def set_symbol_color(self, symbol_color: str):
"""
Change the symbol color of the curve.
Args:
symbol_color(str): Color of the symbol.
"""
self.config.symbol_color = symbol_color
self.apply_config()
def set_symbol_size(self, symbol_size: int):
"""
Change the symbol size of the curve.
Args:
symbol_size(int): Size of the symbol.
"""
self.config.symbol_size = symbol_size
self.apply_config()
def set_pen_width(self, pen_width: int):
"""
Change the pen width of the curve.
Args:
pen_width(int): Width of the pen.
"""
self.config.pen_width = pen_width
self.apply_config()
def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]):
"""
Change the pen style of the curve.
Args:
pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen.
"""
self.config.pen_style = pen_style
self.apply_config()
def set_color_map_z(self, colormap: str):
"""
Set the colormap for the scatter plot z gradient.
Args:
colormap(str): Colormap for the scatter plot.
"""
self.config.color_map_z = colormap
self.apply_config()
self.parent_item.update_with_scan_history(-1)
def get_data(self) -> tuple[np.ndarray, np.ndarray]:
"""
Get the data of the curve.
Returns:
tuple[np.ndarray,np.ndarray]: X and Y data of the curve.
"""
try:
x_data, y_data = self.getData()
except TypeError:
x_data, y_data = np.array([]), np.array([])
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())
self.rpc_register.remove_rpc(self)

View File

@@ -0,0 +1,15 @@
def main(): # pragma: no cover
from qtpy import PYSIDE6
if not PYSIDE6:
print("PYSIDE6 is not available in the environment. Cannot patch designer.")
return
from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
from bec_widgets.widgets.plots_next_gen.waveform.waveform_plugin import WaveformPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
if __name__ == "__main__": # pragma: no cover
main()

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from qtpy.QtWidgets import (
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.error_popups import SafeSlot
from bec_widgets.qt_utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class CurveSetting(SettingWidget):
def __init__(self, parent=None, target_widget: Waveform = None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
self.setProperty("skip_settings", True)
self.setObjectName("CurveSetting")
self.target_widget = target_widget
self.layout = QVBoxLayout(self)
self._init_x_box()
self._init_y_box()
self.setFixedWidth(580) # TODO height is still debate
def _init_x_box(self):
self.x_axis_box = QGroupBox("X Axis")
self.x_axis_box.layout = QHBoxLayout(self.x_axis_box)
self.x_axis_box.layout.setContentsMargins(10, 10, 10, 10)
self.x_axis_box.layout.setSpacing(10)
self.mode_combo_label = QLabel("Mode")
self.mode_combo = QComboBox()
self.mode_combo.addItems(["auto", "index", "timestamp", "device"])
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.device_x_label = QLabel("Device")
self.device_x = DeviceLineEdit()
self._get_x_mode_from_waveform()
self.switch_x_device_selection()
self.mode_combo.currentTextChanged.connect(self.switch_x_device_selection)
self.x_axis_box.layout.addWidget(self.mode_combo_label)
self.x_axis_box.layout.addWidget(self.mode_combo)
self.x_axis_box.layout.addWidget(self.spacer)
self.x_axis_box.layout.addWidget(self.device_x_label)
self.x_axis_box.layout.addWidget(self.device_x)
self.x_axis_box.setFixedHeight(80)
self.layout.addWidget(self.x_axis_box)
def _get_x_mode_from_waveform(self):
if self.target_widget.x_mode in ["auto", "index", "timestamp"]:
self.mode_combo.setCurrentText(self.target_widget.x_mode)
else:
self.mode_combo.setCurrentText("device")
def switch_x_device_selection(self):
if self.mode_combo.currentText() == "device":
self.device_x.setEnabled(True)
self.device_x.setText(self.target_widget.x_axis_mode["name"])
else:
self.device_x.setEnabled(False)
def _init_y_box(self):
self.y_axis_box = QGroupBox("Y Axis")
self.y_axis_box.layout = QVBoxLayout(self.y_axis_box)
self.y_axis_box.layout.setContentsMargins(0, 0, 0, 0)
self.y_axis_box.layout.setSpacing(0)
self.curve_manager = CurveTree(self, waveform=self.target_widget)
self.y_axis_box.layout.addWidget(self.curve_manager)
self.layout.addWidget(self.y_axis_box)
@SafeSlot()
def accept_changes(self):
"""
Accepts the changes made in the settings widget and applies them to the target widget.
"""
if self.mode_combo.currentText() == "device":
self.target_widget.x_mode = self.device_x.text()
else:
self.target_widget.x_mode = self.mode_combo.currentText()
self.curve_manager.send_curve_json()
@SafeSlot()
def refresh(self):
"""Refresh the curve tree and the x axis combo box in the case Waveform is modified from rpc."""
self.curve_manager.refresh_from_waveform()
self._get_x_mode_from_waveform()

View File

@@ -0,0 +1,538 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from bec_qthemes._icon.material_icons import material_icon
from qtpy.QtGui import QColor
from qtpy.QtWidgets import (
QColorDialog,
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSizePolicy,
QSpinBox,
QToolButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bec_widgets.qt_utils.toolbar import MaterialIconAction, ModularToolBar
from bec_widgets.utils import ConnectionConfig, EntryValidator
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.control.device_input.device_line_edit.device_line_edit import (
DeviceLineEdit,
)
from bec_widgets.widgets.dap.dap_combo_box.dap_combo_box import DapComboBox
from bec_widgets.widgets.plots_next_gen.waveform.curve import CurveConfig, DeviceSignal
from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class ColorButton(QPushButton):
"""A QPushButton subclass that displays a color.
The background is set to the given color and the button text is the hex code.
The text color is chosen automatically (black if the background is light, white if dark)
to guarantee good readability.
"""
def __init__(self, color="#000000", parent=None):
"""Initialize the color button.
Args:
color (str): The initial color in hex format (e.g., '#000000').
parent: Optional QWidget parent.
"""
super().__init__(parent)
self.set_color(color)
def set_color(self, color):
"""Set the button's color and update its appearance.
Args:
color (str or QColor): The new color to assign.
"""
if isinstance(color, QColor):
self._color = color.name()
else:
self._color = color
self._update_appearance()
def color(self):
"""Return the current color in hex."""
return self._color
def _update_appearance(self):
"""Update the button style based on the background color's brightness."""
c = QColor(self._color)
brightness = c.lightnessF()
text_color = "#000000" if brightness > 0.5 else "#FFFFFF"
self.setStyleSheet(f"background-color: {self._color}; color: {text_color};")
self.setText(self._color)
class CurveRow(QTreeWidgetItem):
DELETE_BUTTON_COLOR = "#CC181E"
"""A unified row that can represent either a device or a DAP curve.
Columns:
0: Actions (delete or "Add DAP" if source=device)
1..2: DeviceLineEdit and QLineEdit if source=device, or "Model" label and DapComboBox if source=dap
3: ColorButton
4: Style QComboBox
5: Pen width QSpinBox
6: Symbol size QSpinBox
"""
def __init__(
self,
tree: QTreeWidget,
parent_item: QTreeWidgetItem | None = None,
config: CurveConfig | None = None,
device_manager=None,
):
if parent_item:
super().__init__(parent_item)
else:
# A top-level device row.
super().__init__(tree)
self.tree = tree
self.parent_item = parent_item
self.curve_tree = tree.parent() # The CurveTree widget
self.curve_tree.all_items.append(self) # Track stable ordering
self.dev = device_manager
self.entry_validator = EntryValidator(self.dev)
self.config = config or CurveConfig()
self.source = self.config.source
# Create column 0 (Actions)
self._init_actions()
# Create columns 1..2, depending on source
self._init_source_ui()
# Create columns 3..6 (color, style, width, symbol)
self._init_style_controls()
def _init_actions(self):
"""Create the actions widget in column 0, including a delete button and maybe 'Add DAP'."""
self.actions_widget = QWidget()
actions_layout = QHBoxLayout(self.actions_widget)
actions_layout.setContentsMargins(0, 0, 0, 0)
actions_layout.setSpacing(0)
# Delete button
self.delete_button = QToolButton()
delete_icon = material_icon(
"delete",
size=(20, 20),
convert_to_pixmap=False,
filled=False,
color=self.DELETE_BUTTON_COLOR,
)
self.delete_button.setIcon(delete_icon)
self.delete_button.clicked.connect(lambda: self.remove_self())
actions_layout.addWidget(self.delete_button)
# If device row, add "Add DAP" button
if self.source == "device":
self.add_dap_button = QPushButton("DAP")
self.add_dap_button.clicked.connect(lambda: self.add_dap_row())
actions_layout.addWidget(self.add_dap_button)
self.tree.setItemWidget(self, 0, self.actions_widget)
def _init_source_ui(self):
"""Create columns 1 and 2. For device rows, we have device/entry edits; for dap rows, label/model combo."""
if self.source == "device":
# Device row: columns 1..2 are device line edits
self.device_edit = DeviceLineEdit()
self.entry_edit = QLineEdit() # TODO in future will be signal line edit
if self.config.signal:
self.device_edit.setText(self.config.signal.name or "")
self.entry_edit.setText(self.config.signal.entry or "")
self.tree.setItemWidget(self, 1, self.device_edit)
self.tree.setItemWidget(self, 2, self.entry_edit)
else:
# DAP row: column1= "Model" label, column2= DapComboBox
self.label_widget = QLabel("Model")
self.tree.setItemWidget(self, 1, self.label_widget)
self.dap_combo = DapComboBox()
self.dap_combo.populate_fit_model_combobox()
# If config.signal has a dap
if self.config.signal and self.config.signal.dap:
dap_value = self.config.signal.dap
idx = self.dap_combo.fit_model_combobox.findText(dap_value)
if idx >= 0:
self.dap_combo.fit_model_combobox.setCurrentIndex(idx)
else:
self.dap_combo.select_fit_model("GaussianModel") # default
self.tree.setItemWidget(self, 2, self.dap_combo)
def _init_style_controls(self):
"""Create columns 3..6: color button, style combo, width spin, symbol spin."""
# Color in col 3
self.color_button = ColorButton(self.config.color)
self.color_button.clicked.connect(lambda: self._select_color(self.color_button))
self.tree.setItemWidget(self, 3, self.color_button)
# Style in col 4
self.style_combo = QComboBox()
self.style_combo.addItems(["solid", "dash", "dot", "dashdot"])
idx = self.style_combo.findText(self.config.pen_style)
if idx >= 0:
self.style_combo.setCurrentIndex(idx)
self.tree.setItemWidget(self, 4, self.style_combo)
# Pen width in col 5
self.width_spin = QSpinBox()
self.width_spin.setRange(1, 20)
self.width_spin.setValue(self.config.pen_width)
self.tree.setItemWidget(self, 5, self.width_spin)
# Symbol size in col 6
self.symbol_spin = QSpinBox()
self.symbol_spin.setRange(1, 20)
self.symbol_spin.setValue(self.config.symbol_size)
self.tree.setItemWidget(self, 6, self.symbol_spin)
def _select_color(self, button):
"""
Selects a new color using a color dialog and applies it to the specified button. Updates
related configuration properties based on the chosen color.
Args:
button: The button widget whose color is being modified.
"""
current_color = QColor(button.color())
chosen_color = QColorDialog.getColor(current_color, self.tree, "Select Curve Color")
if chosen_color.isValid():
button.set_color(chosen_color)
self.config.color = chosen_color.name()
self.config.symbol_color = chosen_color.name()
def add_dap_row(self):
"""Create a new DAP row as a child. Only valid if source='device'."""
if self.source != "device":
return
curve_tree = self.tree.parent()
parent_label = self.config.label
# Inherit device name/entry
dev_name = ""
dev_entry = ""
if self.config.signal:
dev_name = self.config.signal.name
dev_entry = self.config.signal.entry
# Create a new config for the DAP row
dap_cfg = CurveConfig(
widget_class="Curve",
source="dap",
parent_label=parent_label,
signal=DeviceSignal(name=dev_name, entry=dev_entry),
)
new_dap = CurveRow(self.tree, parent_item=self, config=dap_cfg, device_manager=self.dev)
# Expand device row to show new child
self.tree.expandItem(self)
# Give the new row a color from the buffer:
curve_tree._ensure_color_buffer_size()
idx = len(curve_tree.all_items) - 1
new_col = curve_tree.color_buffer[idx]
new_dap.color_button.set_color(new_col)
new_dap.config.color = new_col
new_dap.config.symbol_color = new_col
def remove_self(self):
"""Remove this row from the tree and from the parent's item list."""
# If top-level:
index = self.tree.indexOfTopLevelItem(self)
if index != -1:
self.tree.takeTopLevelItem(index)
else:
# If child item
if self.parent_item:
self.parent_item.removeChild(self)
# Also remove from all_items
curve_tree = self.tree.parent()
if self in curve_tree.all_items:
curve_tree.all_items.remove(self)
def export_data(self) -> dict:
"""Collect data from the GUI widgets, update config, and return as a dict.
Returns:
dict: The serialized config based on the GUI state.
"""
if self.source == "device":
# Gather device name/entry
device_name = ""
device_entry = ""
if hasattr(self, "device_edit"):
device_name = self.device_edit.text()
if hasattr(self, "entry_edit"):
device_entry = self.entry_validator.validate_signal(
name=device_name, entry=self.entry_edit.text()
)
self.entry_edit.setText(device_entry)
self.config.signal = DeviceSignal(name=device_name, entry=device_entry)
self.config.source = "device"
if not self.config.label:
self.config.label = f"{device_name}-{device_entry}".strip("-")
else:
# DAP logic
parent_conf_dict = {}
if self.parent_item:
parent_conf_dict = self.parent_item.export_data()
parent_conf = CurveConfig(**parent_conf_dict)
dev_name = ""
dev_entry = ""
if parent_conf.signal:
dev_name = parent_conf.signal.name
dev_entry = parent_conf.signal.entry
# Dap from the DapComboBox
new_dap = "GaussianModel"
if hasattr(self, "dap_combo"):
new_dap = self.dap_combo.fit_model_combobox.currentText()
self.config.signal = DeviceSignal(name=dev_name, entry=dev_entry, dap=new_dap)
self.config.source = "dap"
self.config.parent_label = parent_conf.label
self.config.label = f"{parent_conf.label}-{new_dap}".strip("-")
# Common style fields
self.config.color = self.color_button.color()
self.config.symbol_color = self.color_button.color()
self.config.pen_style = self.style_combo.currentText()
self.config.pen_width = self.width_spin.value()
self.config.symbol_size = self.symbol_spin.value()
return self.config.model_dump()
class CurveTree(BECWidget, QWidget):
"""A tree widget that manages device and DAP curves."""
PLUGIN = False
RPC = False
def __init__(
self,
parent: QWidget | None = None,
config: ConnectionConfig | None = None,
client=None,
gui_id: str | None = None,
waveform: Waveform | None = None,
) -> None:
if config is None:
config = ConnectionConfig(widget_class=self.__class__.__name__)
super().__init__(client=client, gui_id=gui_id, config=config)
QWidget.__init__(self, parent=parent)
self.waveform = waveform
if self.waveform and hasattr(self.waveform, "color_palette"):
self.color_palette = self.waveform.color_palette
else:
self.color_palette = "magma"
self.get_bec_shortcuts()
self.color_buffer = []
self.all_items = []
self.layout = QVBoxLayout(self)
self._init_toolbar()
self._init_tree()
self.refresh_from_waveform()
def _init_toolbar(self):
"""Initialize the toolbar with actions: add, send, refresh, expand, collapse, renormalize."""
self.toolbar = ModularToolBar(target_widget=self, orientation="horizontal")
add = MaterialIconAction(
icon_name="add", tooltip="Add new curve", checkable=False, parent=self
)
expand = MaterialIconAction(
icon_name="unfold_more", tooltip="Expand All DAP", checkable=False, parent=self
)
collapse = MaterialIconAction(
icon_name="unfold_less", tooltip="Collapse All DAP", checkable=False, parent=self
)
self.toolbar.add_action("add", add, self)
self.toolbar.add_action("expand_all", expand, self)
self.toolbar.add_action("collapse_all", collapse, self)
# Add colormap widget (not updating waveform's color_palette until Send is pressed)
self.spacer = QWidget()
self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.addWidget(self.spacer)
# Renormalize colors button
renorm_action = MaterialIconAction(
icon_name="palette", tooltip="Normalize All Colors", checkable=False, parent=self
)
self.toolbar.add_action("renormalize_colors", renorm_action, self)
renorm_action.action.triggered.connect(lambda checked: self.renormalize_colors())
self.colormap_widget = BECColorMapWidget(cmap=self.color_palette or "magma")
self.toolbar.addWidget(self.colormap_widget)
self.colormap_widget.colormap_changed_signal.connect(self.handle_colormap_changed)
add.action.triggered.connect(lambda checked: self.add_new_curve())
expand.action.triggered.connect(lambda checked: self.expand_all_daps())
collapse.action.triggered.connect(lambda checked: self.collapse_all_daps())
self.layout.addWidget(self.toolbar)
def _init_tree(self):
"""Initialize the QTreeWidget with 7 columns and compact widths."""
self.tree = QTreeWidget()
self.tree.setColumnCount(7)
self.tree.setHeaderLabels(["Actions", "Name", "Entry", "Color", "Style", "Width", "Symbol"])
self.tree.setColumnWidth(0, 90)
self.tree.setColumnWidth(1, 100)
self.tree.setColumnWidth(2, 100)
self.tree.setColumnWidth(3, 70)
self.tree.setColumnWidth(4, 80)
self.tree.setColumnWidth(5, 40)
self.tree.setColumnWidth(6, 40)
self.layout.addWidget(self.tree)
def _init_color_buffer(self, size: int):
"""
Initializes the color buffer with a calculated set of colors based on the golden
angle sequence.
Args:
size (int): The number of colors to be generated for the color buffer.
"""
self.color_buffer = Colors.golden_angle_color(
colormap=self.colormap_widget.colormap, num=size, format="HEX"
)
def _ensure_color_buffer_size(self):
"""
Ensures that the color buffer size meets the required number of items.
"""
current_count = len(self.color_buffer)
color_list = Colors.golden_angle_color(
colormap=self.color_palette, num=max(10, current_count + 1), format="HEX"
)
self.color_buffer = color_list
def handle_colormap_changed(self, new_cmap: str):
"""
Handles the updating of the color palette when the colormap is changed.
Args:
new_cmap: The new colormap to be set as the color palette.
"""
self.color_palette = new_cmap
def renormalize_colors(self):
"""Overwrite all existing rows with new colors from the buffer in their creation order."""
total = len(self.all_items)
self._ensure_color_buffer_size()
for idx, item in enumerate(self.all_items):
if hasattr(item, "color_button"):
new_col = self.color_buffer[idx]
item.color_button.set_color(new_col)
if hasattr(item, "config"):
item.config.color = new_col
item.config.symbol_color = new_col
def add_new_curve(self, name: str = None, entry: str = None):
"""Add a new device-type CurveRow with an assigned colormap color.
Args:
name (str, optional): Device name.
entry (str, optional): Device entry.
style (str, optional): Pen style. Defaults to "solid".
width (int, optional): Pen width. Defaults to 4.
symbol_size (int, optional): Symbol size. Defaults to 7.
Returns:
CurveRow: The newly created top-level row.
"""
cfg = CurveConfig(
widget_class="Curve",
parent_id=self.waveform.gui_id,
source="device",
signal=DeviceSignal(name=name or "", entry=entry or ""),
)
new_row = CurveRow(self.tree, parent_item=None, config=cfg, device_manager=self.dev)
# Assign color from the buffer ONLY to this new curve.
total_items = len(self.all_items)
self._ensure_color_buffer_size()
color_idx = total_items - 1 # new row is last
new_col = self.color_buffer[color_idx]
new_row.color_button.set_color(new_col)
new_row.config.color = new_col
new_row.config.symbol_color = new_col
return new_row
def send_curve_json(self):
"""Send the current tree's config as JSON to the waveform, updating wavefrom.color_palette as well."""
if self.waveform is not None:
self.waveform.color_palette = self.color_palette
data = self.export_all_curves()
json_data = json.dumps(data, indent=2)
if self.waveform is not None:
self.waveform.curve_json = json_data
def export_all_curves(self) -> list:
"""Recursively export data from each row.
Returns:
list: A list of exported config dicts for every row (device and DAP).
"""
curves = []
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
if isinstance(item, CurveRow):
curves.append(item.export_data())
for j in range(item.childCount()):
child = item.child(j)
if isinstance(child, CurveRow):
curves.append(child.export_data())
return curves
def expand_all_daps(self):
"""Expand all top-level rows to reveal child DAP rows."""
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
self.tree.expandItem(item)
def collapse_all_daps(self):
"""Collapse all top-level rows, hiding child DAP rows."""
for i in range(self.tree.topLevelItemCount()):
item = self.tree.topLevelItem(i)
self.tree.collapseItem(item)
def refresh_from_waveform(self):
"""Clear the tree and rebuild from the waveform's existing curves if any, else add sample rows."""
if self.waveform is None:
return
self.tree.clear()
self.all_items = []
device_curves = [c for c in self.waveform.curves if c.config.source == "device"]
dap_curves = [c for c in self.waveform.curves if c.config.source == "dap"]
for dev in device_curves:
dr = CurveRow(self.tree, parent_item=None, config=dev.config, device_manager=self.dev)
for dap in dap_curves:
if dap.config.parent_label == dev.config.label:
CurveRow(self.tree, parent_item=dr, config=dap.config, device_manager=self.dev)

View File

@@ -0,0 +1,84 @@
import pyqtgraph as pg
from qtpy.QtCore import QObject, Signal, Slot
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
class WaveformROIManager(QObject):
"""
A reusable helper class that manages a single linear ROI region on a given plot item.
It provides signals to notify about region changes and active state.
"""
roi_changed = Signal(tuple) # Emitted when the ROI (left, right) changes
roi_active = Signal(bool) # Emitted when ROI is enabled or disabled
def __init__(self, plot_item: pg.PlotItem, parent=None):
super().__init__(parent)
self._plot_item = plot_item
self._roi_wrapper: LinearRegionWrapper | None = None
self._roi_region: tuple[float, float] | None = None
self._accent_colors = get_accent_colors()
@property
def roi_region(self) -> tuple[float, float] | None:
return self._roi_region
@roi_region.setter
def roi_region(self, value: tuple[float, float] | None):
self._roi_region = value
if self._roi_wrapper is not None and value is not None:
self._roi_wrapper.linear_region_selector.setRegion(value)
@Slot(bool)
def toggle_roi(self, enabled: bool) -> None:
if enabled:
self._enable_roi()
else:
self._disable_roi()
@Slot(tuple)
def select_roi(self, region: tuple[float, float]):
# If ROI not present, enabling it
if self._roi_wrapper is None:
self.toggle_roi(True)
self.roi_region = region
def _enable_roi(self):
if self._roi_wrapper is not None:
# Already enabled
return
color = self._accent_colors.default
color.setAlpha(int(0.2 * 255))
hover_color = self._accent_colors.default
hover_color.setAlpha(int(0.35 * 255))
self._roi_wrapper = LinearRegionWrapper(
self._plot_item, color=color, hover_color=hover_color, parent=self
)
self._roi_wrapper.add_region_selector()
self._roi_wrapper.region_changed.connect(self._on_region_changed)
# If we already had a region, apply it
if self._roi_region is not None:
self._roi_wrapper.linear_region_selector.setRegion(self._roi_region)
else:
self._roi_region = self._roi_wrapper.linear_region_selector.getRegion()
self.roi_active.emit(True)
def _disable_roi(self):
if self._roi_wrapper is not None:
self._roi_wrapper.region_changed.disconnect(self._on_region_changed)
self._roi_wrapper.cleanup()
self._roi_wrapper.deleteLater()
self._roi_wrapper = None
self._roi_region = None
self.roi_active.emit(False)
@Slot(tuple)
def _on_region_changed(self, region: tuple[float, float]):
self._roi_region = region
self.roi_changed.emit(region)

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,54 @@
# Copyright (C) 2022 The Qt Company Ltd.
# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
from qtpy.QtDesigner import QDesignerCustomWidgetInterface
from bec_widgets.utils.bec_designer import designer_material_icon
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
DOM_XML = """
<ui language='c++'>
<widget class='Waveform' name='waveform'>
</widget>
</ui>
"""
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = Waveform(parent)
return t
def domXml(self):
return DOM_XML
def group(self):
return "Plot Widgets Next Gen"
def icon(self):
return designer_material_icon(Waveform.ICON_NAME)
def includeFile(self):
return "waveform"
def initialize(self, form_editor):
self._form_editor = form_editor
def isContainer(self):
return False
def isInitialized(self):
return self._form_editor is not None
def name(self):
return "Waveform"
def toolTip(self):
return "Waveform"
def whatsThis(self):
return self.toolTip()

View File

@@ -27,7 +27,9 @@ def gui_id():
@contextmanager
def plot_server(gui_id, klass, client_lib):
dispatcher = BECDispatcher(client=client_lib) # Has to init singleton with fixture client
process, _ = _start_plot_process(gui_id, klass, client_lib._client._service_config.config_path)
process, _ = _start_plot_process(
gui_id, klass, gui_class_id="bec", config=client_lib._client._service_config.config_path
)
try:
while client_lib._client.connector.get(MessageEndpoints.gui_heartbeat(gui_id)) is None:
time.sleep(0.3)
@@ -42,6 +44,7 @@ def plot_server(gui_id, klass, client_lib):
@pytest.fixture
def connected_client_figure(gui_id, bec_client_lib):
with plot_server(gui_id, BECFigure, bec_client_lib) as server:
yield server
@@ -49,10 +52,11 @@ def connected_client_figure(gui_id, bec_client_lib):
def connected_client_gui_obj(gui_id, bec_client_lib):
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start_server(wait=True)
gui.start(wait=True)
# gui._start_server(wait=True)
yield gui
finally:
gui.close()
gui.kill_server()
@pytest.fixture
@@ -60,17 +64,18 @@ def connected_client_dock(gui_id, bec_client_lib):
gui = BECGuiClient(gui_id=gui_id)
gui._auto_updates_enabled = False
try:
gui.start_server(wait=True)
yield gui.main
gui.start(wait=True)
gui.window_list[0]
yield gui.window_list[0]
finally:
gui.close()
gui.kill_server()
@pytest.fixture
def connected_client_dock_w_auto_updates(gui_id, bec_client_lib):
gui = BECGuiClient(gui_id=gui_id)
try:
gui.start_server(wait=True)
yield gui, gui.main
gui._start_server(wait=True)
yield gui, gui.window_list[0]
finally:
gui.close()
gui.kill_server()

View File

@@ -1,378 +1,378 @@
import time
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
from bec_widgets.utils import Colors
# pylint: disable=unused-argument
# pylint: disable=redefined-outer-name
# pylint: disable=too-many-locals
def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
# BEC client shortcuts
dock = connected_client_dock
client = bec_client_lib
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
# Create 3 docks
d0 = dock.add_dock("dock_0")
d1 = dock.add_dock("dock_1")
d2 = dock.add_dock("dock_2")
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 3
# Add 3 figures with some widgets
fig0 = d0.add_widget("BECFigure")
fig1 = d1.add_widget("BECFigure")
fig2 = d2.add_widget("BECFigure")
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 3
assert len(dock_config["docks"]["dock_0"]["widgets"]) == 1
assert len(dock_config["docks"]["dock_1"]["widgets"]) == 1
assert len(dock_config["docks"]["dock_2"]["widgets"]) == 1
assert fig1.__class__.__name__ == "BECFigure"
assert fig1.__class__ == BECFigure
assert fig2.__class__.__name__ == "BECFigure"
assert fig2.__class__ == BECFigure
mm = fig0.motor_map("samx", "samy")
plt = fig1.plot(x_name="samx", y_name="bpm4i")
im = fig2.image("eiger")
assert mm.__class__.__name__ == "BECMotorMap"
assert mm.__class__ == BECMotorMap
assert plt.__class__.__name__ == "BECWaveform"
assert plt.__class__ == BECWaveform
assert im.__class__.__name__ == "BECImageShow"
assert im.__class__ == BECImageShow
assert mm._config_dict["signals"] == {
"dap": None,
"source": "device_readback",
"x": {
"name": "samx",
"entry": "samx",
"unit": None,
"modifier": None,
"limits": [-50.0, 50.0],
},
"y": {
"name": "samy",
"entry": "samy",
"unit": None,
"modifier": None,
"limits": [-50.0, 50.0],
},
"z": None,
}
assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
"dap": None,
"source": "scan_segment",
"x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
"y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
"z": None,
}
assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
# check initial position of motor map
initial_pos_x = dev.samx.read()["samx"]["value"]
initial_pos_y = dev.samy.read()["samy"]["value"]
# Try to make a scan
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
# plot
item = queue.scan_storage.storage[-1]
plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = 10
plot_name = "bpm4i-bpm4i"
qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
plt_data = plt.get_all_data()
assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
# image
last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
"data"
].data
time.sleep(0.5)
last_image_plot = im.images[0].get_data()
np.testing.assert_equal(last_image_device, last_image_plot)
# motor map
final_pos_x = dev.samx.read()["samx"]["value"]
final_pos_y = dev.samy.read()["samy"]["value"]
# check final coordinates of motor map
motor_map_data = mm.get_data()
np.testing.assert_equal(
[motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
)
np.testing.assert_equal(
[motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
)
def test_dock_manipulations_e2e(connected_client_dock):
dock = connected_client_dock
d0 = dock.add_dock("dock_0")
d1 = dock.add_dock("dock_1")
d2 = dock.add_dock("dock_2")
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 3
d0.detach()
dock.detach_dock("dock_2")
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 3
assert len(dock.temp_areas) == 2
d0.attach()
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 3
assert len(dock.temp_areas) == 1
d2.remove()
dock_config = dock._config_dict
assert ["dock_0", "dock_1"] == list(dock_config["docks"])
dock.clear_all()
dock_config = dock._config_dict
assert len(dock_config["docks"]) == 0
assert len(dock.temp_areas) == 0
def test_ring_bar(connected_client_dock):
dock = connected_client_dock
d0 = dock.add_dock(name="dock_0")
bar = d0.add_widget("RingProgressBar")
assert bar.__class__.__name__ == "RingProgressBar"
# import time
# import numpy as np
# import pytest
# from bec_lib.endpoints import MessageEndpoints
# from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
# from bec_widgets.tests.utils import check_remote_data_size
# from bec_widgets.utils import Colors
# # pylint: disable=unused-argument
# # pylint: disable=redefined-outer-name
# # pylint: disable=too-many-locals
# def test_rpc_add_dock_with_figure_e2e(qtbot, bec_client_lib, connected_client_dock):
# # BEC client shortcuts
# dock = connected_client_dock
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# queue = client.queue
# # Create 3 docks
# d0 = dock.add_dock("dock_0")
# d1 = dock.add_dock("dock_1")
# d2 = dock.add_dock("dock_2")
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 3
# # Add 3 figures with some widgets
# fig0 = d0.add_widget("BECFigure")
# fig1 = d1.add_widget("BECFigure")
# fig2 = d2.add_widget("BECFigure")
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 3
# assert len(dock_config["docks"]["dock_0"]["widgets"]) == 1
# assert len(dock_config["docks"]["dock_1"]["widgets"]) == 1
# assert len(dock_config["docks"]["dock_2"]["widgets"]) == 1
# assert fig1.__class__.__name__ == "BECFigure"
# assert fig1.__class__ == BECFigure
# assert fig2.__class__.__name__ == "BECFigure"
# assert fig2.__class__ == BECFigure
# mm = fig0.motor_map("samx", "samy")
# plt = fig1.plot(x_name="samx", y_name="bpm4i")
# im = fig2.image("eiger")
# assert mm.__class__.__name__ == "BECMotorMap"
# assert mm.__class__ == BECMotorMap
# assert plt.__class__.__name__ == "BECWaveform"
# assert plt.__class__ == BECWaveform
# assert im.__class__.__name__ == "BECImageShow"
# assert im.__class__ == BECImageShow
# assert mm._config_dict["signals"] == {
# "dap": None,
# "source": "device_readback",
# "x": {
# "name": "samx",
# "entry": "samx",
# "unit": None,
# "modifier": None,
# "limits": [-50.0, 50.0],
# },
# "y": {
# "name": "samy",
# "entry": "samy",
# "unit": None,
# "modifier": None,
# "limits": [-50.0, 50.0],
# },
# "z": None,
# }
# assert plt._config_dict["curves"]["bpm4i-bpm4i"]["signals"] == {
# "dap": None,
# "source": "scan_segment",
# "x": {"name": "samx", "entry": "samx", "unit": None, "modifier": None, "limits": None},
# "y": {"name": "bpm4i", "entry": "bpm4i", "unit": None, "modifier": None, "limits": None},
# "z": None,
# }
# assert im._config_dict["images"]["eiger"]["monitor"] == "eiger"
# # check initial position of motor map
# initial_pos_x = dev.samx.read()["samx"]["value"]
# initial_pos_y = dev.samy.read()["samy"]["value"]
# # Try to make a scan
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# status.wait()
# # plot
# item = queue.scan_storage.storage[-1]
# plt_last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
# num_elements = 10
# plot_name = "bpm4i-bpm4i"
# qtbot.waitUntil(lambda: check_remote_data_size(plt, plot_name, num_elements))
# plt_data = plt.get_all_data()
# assert plt_data["bpm4i-bpm4i"]["x"] == plt_last_scan_data["samx"]["samx"].val
# assert plt_data["bpm4i-bpm4i"]["y"] == plt_last_scan_data["bpm4i"]["bpm4i"].val
# # image
# last_image_device = client.connector.get_last(MessageEndpoints.device_monitor_2d("eiger"))[
# "data"
# ].data
# time.sleep(0.5)
# last_image_plot = im.images[0].get_data()
# np.testing.assert_equal(last_image_device, last_image_plot)
# # motor map
# final_pos_x = dev.samx.read()["samx"]["value"]
# final_pos_y = dev.samy.read()["samy"]["value"]
# # check final coordinates of motor map
# motor_map_data = mm.get_data()
# np.testing.assert_equal(
# [motor_map_data["x"][0], motor_map_data["y"][0]], [initial_pos_x, initial_pos_y]
# )
# np.testing.assert_equal(
# [motor_map_data["x"][-1], motor_map_data["y"][-1]], [final_pos_x, final_pos_y]
# )
# def test_dock_manipulations_e2e(connected_client_dock):
# dock = connected_client_dock
# d0 = dock.add_dock("dock_0")
# d1 = dock.add_dock("dock_1")
# d2 = dock.add_dock("dock_2")
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 3
# d0.detach()
# dock.detach_dock("dock_2")
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 3
# assert len(dock.temp_areas) == 2
# d0.attach()
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 3
# assert len(dock.temp_areas) == 1
# d2.remove()
# dock_config = dock._config_dict
# assert ["dock_0", "dock_1"] == list(dock_config["docks"])
# dock.clear_all()
# dock_config = dock._config_dict
# assert len(dock_config["docks"]) == 0
# assert len(dock.temp_areas) == 0
# def test_ring_bar(connected_client_dock):
# dock = connected_client_dock
# d0 = dock.add_dock(name="dock_0")
# bar = d0.add_widget("RingProgressBar")
# assert bar.__class__.__name__ == "RingProgressBar"
bar.set_number_of_bars(5)
bar.set_colors_from_map("viridis")
bar.set_value([10, 20, 30, 40, 50])
# bar.set_number_of_bars(5)
# bar.set_colors_from_map("viridis")
# bar.set_value([10, 20, 30, 40, 50])
bar_config = bar._config_dict
# bar_config = bar._config_dict
expected_colors_light = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
]
expected_colors_dark = [
list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
]
bar_colors = [ring._config_dict["color"] for ring in bar.rings]
bar_values = [ring._config_dict["value"] for ring in bar.rings]
assert bar_config["num_bars"] == 5
assert bar_values == [10, 20, 30, 40, 50]
assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
# expected_colors_light = [
# list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="light")
# ]
# expected_colors_dark = [
# list(color) for color in Colors.golden_angle_color("viridis", 5, "RGB", theme="dark")
# ]
# bar_colors = [ring._config_dict["color"] for ring in bar.rings]
# bar_values = [ring._config_dict["value"] for ring in bar.rings]
# assert bar_config["num_bars"] == 5
# assert bar_values == [10, 20, 30, 40, 50]
# assert bar_colors == expected_colors_light or bar_colors == expected_colors_dark
def test_ring_bar_scan_update(bec_client_lib, connected_client_dock):
dock = connected_client_dock
# def test_ring_bar_scan_update(bec_client_lib, connected_client_dock):
# dock = connected_client_dock
d0 = dock.add_dock("dock_0")
# d0 = dock.add_dock("dock_0")
bar = d0.add_widget("RingProgressBar")
# bar = d0.add_widget("RingProgressBar")
client = bec_client_lib
dev = client.device_manager.devices
dev.samx.tolerance.set(0)
dev.samy.tolerance.set(0)
scans = client.scans
# client = bec_client_lib
# dev = client.device_manager.devices
# dev.samx.tolerance.set(0)
# dev.samy.tolerance.set(0)
# scans = client.scans
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# status.wait()
bar_config = bar._config_dict
assert bar_config["num_bars"] == 1
assert bar_config["rings"][0]["value"] == 10
assert bar_config["rings"][0]["min_value"] == 0
assert bar_config["rings"][0]["max_value"] == 10
# bar_config = bar._config_dict
# assert bar_config["num_bars"] == 1
# assert bar_config["rings"][0]["value"] == 10
# assert bar_config["rings"][0]["min_value"] == 0
# assert bar_config["rings"][0]["max_value"] == 10
status = scans.grid_scan(dev.samx, -5, 5, 4, dev.samy, -10, 10, 4, relative=True, exp_time=0.1)
status.wait()
# status = scans.grid_scan(dev.samx, -5, 5, 4, dev.samy, -10, 10, 4, relative=True, exp_time=0.1)
# status.wait()
bar_config = bar._config_dict
assert bar_config["num_bars"] == 1
assert bar_config["rings"][0]["value"] == 16
assert bar_config["rings"][0]["min_value"] == 0
assert bar_config["rings"][0]["max_value"] == 16
# bar_config = bar._config_dict
# assert bar_config["num_bars"] == 1
# assert bar_config["rings"][0]["value"] == 16
# assert bar_config["rings"][0]["min_value"] == 0
# assert bar_config["rings"][0]["max_value"] == 16
init_samx = dev.samx.read()["samx"]["value"]
init_samy = dev.samy.read()["samy"]["value"]
final_samx = init_samx + 5
final_samy = init_samy + 10
# init_samx = dev.samx.read()["samx"]["value"]
# init_samy = dev.samy.read()["samy"]["value"]
# final_samx = init_samx + 5
# final_samy = init_samy + 10
dev.samx.velocity.put(5)
dev.samy.velocity.put(5)
# dev.samx.velocity.put(5)
# dev.samy.velocity.put(5)
status = scans.umv(dev.samx, 5, dev.samy, 10, relative=True)
status.wait()
# status = scans.umv(dev.samx, 5, dev.samy, 10, relative=True)
# status.wait()
bar_config = bar._config_dict
assert bar_config["num_bars"] == 2
assert bar_config["rings"][0]["value"] == final_samx
assert bar_config["rings"][1]["value"] == final_samy
assert bar_config["rings"][0]["min_value"] == init_samx
assert bar_config["rings"][0]["max_value"] == final_samx
assert bar_config["rings"][1]["min_value"] == init_samy
assert bar_config["rings"][1]["max_value"] == final_samy
# bar_config = bar._config_dict
# assert bar_config["num_bars"] == 2
# assert bar_config["rings"][0]["value"] == final_samx
# assert bar_config["rings"][1]["value"] == final_samy
# assert bar_config["rings"][0]["min_value"] == init_samx
# assert bar_config["rings"][0]["max_value"] == final_samx
# assert bar_config["rings"][1]["min_value"] == init_samy
# assert bar_config["rings"][1]["max_value"] == final_samy
def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot):
client = bec_client_lib
dev = client.device_manager.devices
scans = client.scans
queue = client.queue
gui, dock = connected_client_dock_w_auto_updates
auto_updates = gui.auto_updates
# def test_auto_update(bec_client_lib, connected_client_dock_w_auto_updates, qtbot):
# client = bec_client_lib
# dev = client.device_manager.devices
# scans = client.scans
# queue = client.queue
# gui, dock = connected_client_dock_w_auto_updates
# auto_updates = gui.auto_updates
def get_default_figure():
return auto_updates.get_default_figure()
# def get_default_figure():
# return auto_updates.get_default_figure()
plt = get_default_figure()
gui.selected_device = "bpm4i"
status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
status.wait()
# get data from curves
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
num_elements = 10
plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
== last_scan_data["samx"]["samx"].val
)
assert (
plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["y"]
== last_scan_data["bpm4i"]["bpm4i"].val
)
status = scans.grid_scan(
dev.samx, -10, 10, 5, dev.samy, -5, 5, 5, exp_time=0.05, relative=False
)
status.wait()
plt = auto_updates.get_default_figure()
widgets = plt.widget_list
qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
item = queue.scan_storage.storage[-1]
last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
plot_name = f"Scan {status.scan.scan_number} - bpm4i"
num_elements_bec = 25
qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
plt_data = widgets[0].get_all_data()
# check plotted data
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
== last_scan_data["samx"]["samx"].val
)
assert (
plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
== last_scan_data["samy"]["samy"].val
)
def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
gui = connected_client_gui_obj
assert gui.selected_device is None
assert len(gui.windows) == 1
assert gui.windows["main"].widget is gui.main
assert gui.windows["main"].title == "BEC Widgets"
mw = gui.main
assert mw.__class__.__name__ == "BECDockArea"
xw = gui.new("X")
assert xw.__class__.__name__ == "BECDockArea"
assert len(gui.windows) == 2
gui_info = gui._dump()
mw_info = gui_info[mw._gui_id]
assert mw_info["title"] == "BEC Widgets"
assert mw_info["visible"]
xw_info = gui_info[xw._gui_id]
assert xw_info["title"] == "X"
assert xw_info["visible"]
gui.hide()
gui_info = gui._dump()
assert not any(windows["visible"] for windows in gui_info.values())
gui.show()
gui_info = gui._dump()
assert all(windows["visible"] for windows in gui_info.values())
assert gui.gui_is_alive()
gui.close()
assert not gui.gui_is_alive()
gui.start_server(wait=True)
assert gui.gui_is_alive()
# calling start multiple times should not change anything
gui.start_server(wait=True)
gui.start()
# gui.windows should have main, and main dock area should have same gui_id as before
assert len(gui.windows) == 1
assert gui.windows["main"].widget._gui_id == mw._gui_id
# communication should work, main dock area should have same id and be visible
gui_info = gui._dump()
assert gui_info[mw._gui_id]["visible"]
with pytest.raises(RuntimeError):
gui.main.delete()
yw = gui.new("Y")
assert len(gui.windows) == 2
yw.delete()
assert len(gui.windows) == 1
# check it is really deleted on server
gui_info = gui._dump()
assert yw._gui_id not in gui_info
def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot):
gui = connected_client_gui_obj
gui.main.add_dock("test")
qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
qtbot.wait(500)
with pytest.raises(ValueError):
gui.main.add_dock("test")
# time.sleep(0.1)
# plt = get_default_figure()
# gui.selected_device = "bpm4i"
# status = scans.line_scan(dev.samx, -5, 5, steps=10, exp_time=0.05, relative=False)
# status.wait()
# # get data from curves
# widgets = plt.widget_list
# qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
# item = queue.scan_storage.storage[-1]
# last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
# num_elements = 10
# plot_name = f"Scan {status.scan.scan_number} - {dock.selected_device}"
# qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements))
# plt_data = widgets[0].get_all_data()
# # check plotted data
# assert (
# plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["x"]
# == last_scan_data["samx"]["samx"].val
# )
# assert (
# plt_data[f"Scan {status.scan.scan_number} - bpm4i"]["y"]
# == last_scan_data["bpm4i"]["bpm4i"].val
# )
# status = scans.grid_scan(
# dev.samx, -10, 10, 5, dev.samy, -5, 5, 5, exp_time=0.05, relative=False
# )
# status.wait()
# plt = auto_updates.get_default_figure()
# widgets = plt.widget_list
# qtbot.waitUntil(lambda: len(plt.widget_list) > 0, timeout=5000)
# item = queue.scan_storage.storage[-1]
# last_scan_data = item.live_data if hasattr(item, "live_data") else item.data
# plot_name = f"Scan {status.scan.scan_number} - bpm4i"
# num_elements_bec = 25
# qtbot.waitUntil(lambda: check_remote_data_size(widgets[0], plot_name, num_elements_bec))
# plt_data = widgets[0].get_all_data()
# # check plotted data
# assert (
# plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["x"]
# == last_scan_data["samx"]["samx"].val
# )
# assert (
# plt_data[f"Scan {status.scan.scan_number} - {dock.selected_device}"]["y"]
# == last_scan_data["samy"]["samy"].val
# )
# def test_rpc_gui_obj(connected_client_gui_obj, qtbot):
# gui = connected_client_gui_obj
# assert gui.selected_device is None
# assert len(gui.windows) == 1
# assert gui.windows["main"].widget is gui.main
# assert gui.windows["main"].title == "BEC Widgets"
# mw = gui.main
# assert mw.__class__.__name__ == "BECDockArea"
# xw = gui.new("X")
# assert xw.__class__.__name__ == "BECDockArea"
# assert len(gui.windows) == 2
# gui_info = gui._dump()
# mw_info = gui_info[mw._gui_id]
# assert mw_info["title"] == "BEC Widgets"
# assert mw_info["visible"]
# xw_info = gui_info[xw._gui_id]
# assert xw_info["title"] == "X"
# assert xw_info["visible"]
# gui.hide()
# gui_info = gui._dump()
# assert not any(windows["visible"] for windows in gui_info.values())
# gui.show()
# gui_info = gui._dump()
# assert all(windows["visible"] for windows in gui_info.values())
# assert gui.gui_is_alive()
# gui.close()
# assert not gui.gui_is_alive()
# gui.start_server(wait=True)
# assert gui.gui_is_alive()
# # calling start multiple times should not change anything
# gui.start_server(wait=True)
# gui.start()
# # gui.windows should have main, and main dock area should have same gui_id as before
# assert len(gui.windows) == 1
# assert gui.windows["main"].widget._gui_id == mw._gui_id
# # communication should work, main dock area should have same id and be visible
# gui_info = gui._dump()
# assert gui_info[mw._gui_id]["visible"]
# with pytest.raises(RuntimeError):
# gui.main.delete()
# yw = gui.new("Y")
# assert len(gui.windows) == 2
# yw.delete()
# assert len(gui.windows) == 1
# # check it is really deleted on server
# gui_info = gui._dump()
# assert yw._gui_id not in gui_info
# def test_rpc_call_with_exception_in_safeslot_error_popup(connected_client_gui_obj, qtbot):
# gui = connected_client_gui_obj
# gui.main.add_dock("test")
# qtbot.waitUntil(lambda: len(gui.main.panels) == 2) # default_figure + test
# qtbot.wait(500)
# with pytest.raises(ValueError):
# gui.main.add_dock("test")
# # time.sleep(0.1)

View File

@@ -1,14 +1,23 @@
import time
import numpy as np
import pytest
from bec_lib.endpoints import MessageEndpoints
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
from bec_widgets.tests.utils import check_remote_data_size
def test_rpc_waveform1d_custom_curve(connected_client_figure):
fig = BECFigure(connected_client_figure)
@pytest.fixture
def connected_figure(connected_client_gui_obj):
gui = connected_client_gui_obj
dock = gui.window_list[0].new("dock")
fig = dock.new(name="fig", widget="BECFigure")
return fig
def test_rpc_waveform1d_custom_curve(connected_figure):
fig = connected_figure
ax = fig.plot()
curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
@@ -20,8 +29,8 @@ def test_rpc_waveform1d_custom_curve(connected_client_figure):
assert len(fig.widgets[ax._rpc_id].curves) == 1
def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
fig = BECFigure(connected_client_figure)
def test_rpc_plotting_shortcuts_init_configs(connected_figure, qtbot):
fig = connected_figure
plt = fig.plot(x_name="samx", y_name="bpm4i")
im = fig.image("eiger")
@@ -78,9 +87,8 @@ def test_rpc_plotting_shortcuts_init_configs(connected_client_figure, qtbot):
}
def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
def test_rpc_waveform_scan(qtbot, connected_figure, bec_client_lib):
fig = connected_figure
# add 3 different curves to track
plt = fig.plot(x_name="samx", y_name="bpm4i")
fig.plot(x_name="samx", y_name="bpm3a")
@@ -114,8 +122,8 @@ def test_rpc_waveform_scan(qtbot, connected_client_figure, bec_client_lib):
assert plt_data["bpm4d-bpm4d"]["y"] == last_scan_data["bpm4d"]["bpm4d"].val
def test_rpc_image(connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
def test_rpc_image(connected_figure, bec_client_lib):
fig = connected_figure
im = fig.image("eiger")
@@ -135,8 +143,8 @@ def test_rpc_image(connected_client_figure, bec_client_lib):
np.testing.assert_equal(last_image_device, last_image_plot)
def test_rpc_motor_map(connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
def test_rpc_motor_map(connected_figure, bec_client_lib):
fig = connected_figure
motor_map = fig.motor_map("samx", "samy")
@@ -164,9 +172,9 @@ def test_rpc_motor_map(connected_client_figure, bec_client_lib):
)
def test_dap_rpc(connected_client_figure, bec_client_lib, qtbot):
def test_dap_rpc(connected_figure, bec_client_lib, qtbot):
fig = BECFigure(connected_client_figure)
fig = connected_figure
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
client = bec_client_lib
@@ -204,8 +212,8 @@ def test_dap_rpc(connected_client_figure, bec_client_lib, qtbot):
qtbot.waitUntil(wait_for_fit, timeout=10000)
def test_removing_subplots(connected_client_figure, bec_client_lib):
fig = BECFigure(connected_client_figure)
def test_removing_subplots(connected_figure, bec_client_lib):
fig = connected_figure
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
im = fig.image(monitor="eiger")
mm = fig.motor_map(motor_x="samx", motor_y="samy")

View File

@@ -3,8 +3,9 @@ import pytest
from bec_widgets.cli.client import BECFigure, BECImageShow, BECMotorMap, BECWaveform
def test_rpc_register_list_connections(connected_client_figure):
fig = BECFigure(connected_client_figure)
def test_rpc_register_list_connections(connected_client_gui_obj):
gui = connected_client_gui_obj
fig = gui.window_list[0].new("fig").new(name="fig", widget="BECFigure")
plt = fig.plot(x_name="samx", y_name="bpm4i")
im = fig.image("eiger")
@@ -36,5 +37,6 @@ def test_rpc_register_list_connections(connected_client_figure):
**image_item_expected,
}
assert len(all_connections) == 9
assert all_connections == all_connections_expected
assert len(all_connections) == 9 + 3 # gui, dock_area, dock
# In the old implementation, gui , dock_area and dock were not included in the _get_all_rpc() method
# assert all_connections == all_connections_expected

View File

@@ -1,8 +1,11 @@
# pylint: disable = no-name-in-module,missing-class-docstring, missing-module-docstring
from math import inf
from unittest.mock import MagicMock, patch
import fakeredis
import pytest
from bec_lib.bec_service import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.redis_connector import RedisConnector
from bec_widgets.tests.utils import DEVICES, DMMock, FakePositioner, Positioner
@@ -50,3 +53,188 @@ def mocked_client(bec_dispatcher):
with patch("builtins.isinstance", new=isinstance_mock):
yield client
connector.shutdown() # TODO change to real BECClient
##################################################
# Client Fixture with DAP
##################################################
@pytest.fixture(scope="function")
def dap_plugin_message():
msg = messages.AvailableResourceMessage(
**{
"resource": {
"GaussianModel": {
"class": "LmfitService1D",
"user_friendly_name": "GaussianModel",
"class_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n ",
"run_doc": "A model based on a Gaussian or normal distribution lineshape.\n\n The model has three Parameters: `amplitude`, `center`, and `sigma`.\n In addition, parameters `fwhm` and `height` are included as\n constraints to report full width at half maximum and maximum peak\n height, respectively.\n\n .. math::\n\n f(x; A, \\mu, \\sigma) = \\frac{A}{\\sigma\\sqrt{2\\pi}} e^{[{-{(x-\\mu)^2}/{{2\\sigma}^2}}]}\n\n where the parameter `amplitude` corresponds to :math:`A`, `center` to\n :math:`\\mu`, and `sigma` to :math:`\\sigma`. The full width at half\n maximum is :math:`2\\sigma\\sqrt{2\\ln{2}}`, approximately\n :math:`2.3548\\sigma`.\n\n For more information, see: https://en.wikipedia.org/wiki/Normal_distribution\n\n \n Args:\n scan_item (ScanItem): Scan item or scan ID\n device_x (DeviceBase | str): Device name for x\n signal_x (DeviceBase | str): Signal name for x\n device_y (DeviceBase | str): Device name for y\n signal_y (DeviceBase | str): Signal name for y\n parameters (dict): Fit parameters\n ",
"run_name": "fit",
"signature": [
{
"name": "args",
"kind": "VAR_POSITIONAL",
"default": "_empty",
"annotation": "_empty",
},
{
"name": "scan_item",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "ScanItem | str",
},
{
"name": "device_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_x",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "device_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "signal_y",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "DeviceBase | str",
},
{
"name": "parameters",
"kind": "KEYWORD_ONLY",
"default": None,
"annotation": "dict",
},
{
"name": "kwargs",
"kind": "VAR_KEYWORD",
"default": "_empty",
"annotation": "_empty",
},
],
"auto_fit_supported": True,
"params": {
"amplitude": {
"name": "amplitude",
"value": 1.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"center": {
"name": "center",
"value": 0.0,
"vary": True,
"min": -inf,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"sigma": {
"name": "sigma",
"value": 1.0,
"vary": True,
"min": 0,
"max": inf,
"expr": None,
"brute_step": None,
"user_data": None,
},
"fwhm": {
"name": "fwhm",
"value": 2.35482,
"vary": False,
"min": -inf,
"max": inf,
"expr": "2.3548200*sigma",
"brute_step": None,
"user_data": None,
},
"height": {
"name": "height",
"value": 0.3989423,
"vary": False,
"min": -inf,
"max": inf,
"expr": "0.3989423*amplitude/max(1e-15, sigma)",
"brute_step": None,
"user_data": None,
},
},
"class_args": [],
"class_kwargs": {"model": "GaussianModel"},
}
}
}
)
yield msg
@pytest.fixture(scope="function")
def mocked_client_with_dap(mocked_client, dap_plugin_message):
dap_services = {
"BECClient": messages.StatusMessage(name="BECClient", status=1, info={}),
"DAPServer/LmfitService1D": messages.StatusMessage(
name="LmfitService1D", status=1, info={}
),
}
client = mocked_client
client.service_status = dap_services
client.connector.set(
topic=MessageEndpoints.dap_available_plugins("dap"), msg=dap_plugin_message
)
# Patch the client's DAP attribute so that the available models include "GaussianModel"
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
client.dap._available_dap_plugins = patched_models
yield client
class DummyData:
def __init__(self, val, timestamps):
self.val = val
self.timestamps = timestamps
def get(self, key, default=None):
if key == "val":
return self.val
return default
def create_dummy_scan_item():
"""
Helper to create a dummy scan item with both live_data and metadata/status_message info.
"""
dummy_live_data = {
"samx": {"samx": DummyData(val=[10, 20, 30], timestamps=[100, 200, 300])},
"samy": {"samy": DummyData(val=[5, 10, 15], timestamps=[100, 200, 300])},
"bpm4i": {"bpm4i": DummyData(val=[5, 6, 7], timestamps=[101, 201, 301])},
"async_device": {"async_device": DummyData(val=[1, 2, 3], timestamps=[11, 21, 31])},
}
dummy_scan = MagicMock()
dummy_scan.live_data = dummy_live_data
dummy_scan.metadata = {
"bec": {
"scan_id": "dummy",
"scan_report_devices": ["samx"],
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
}
}
dummy_scan.status_message = MagicMock()
dummy_scan.status_message.info = {
"readout_priority": {"monitored": ["bpm4i"], "async": ["async_device"]},
"scan_report_devices": ["samx"],
}
return dummy_scan

View File

@@ -30,7 +30,7 @@ def test_bec_connector_init_with_gui_id(mocked_client):
def test_bec_connector_set_gui_id(bec_connector):
bec_connector.set_gui_id("test_gui_id")
bec_connector._set_gui_id("test_gui_id")
assert bec_connector.config.gui_id == "test_gui_id"
@@ -40,7 +40,7 @@ def test_bec_connector_change_config(bec_connector):
def test_bec_connector_get_obj_by_id(bec_connector):
bec_connector.set_gui_id("test_gui_id")
bec_connector._set_gui_id("test_gui_id")
assert bec_connector.get_obj_by_id("test_gui_id") == bec_connector
assert bec_connector.get_obj_by_id("test_gui_id_2") is None

View File

@@ -28,9 +28,9 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
initial_count = len(bec_dock_area.dock_area.docks)
# Adding 3 docks
d0 = bec_dock_area.add_dock()
d1 = bec_dock_area.add_dock()
d2 = bec_dock_area.add_dock()
d0 = bec_dock_area.new()
d1 = bec_dock_area.new()
d2 = bec_dock_area.new()
# Check if the docks were added
assert len(bec_dock_area.dock_area.docks) == initial_count + 3
@@ -46,7 +46,7 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
# Remove docks
d0_name = d0.name()
bec_dock_area.remove_dock(d0_name)
bec_dock_area.delete(d0_name)
qtbot.wait(200)
d1.remove()
qtbot.wait(200)
@@ -58,16 +58,16 @@ def test_bec_dock_area_add_remove_dock(bec_dock_area, qtbot):
def test_add_remove_bec_figure_to_dock(bec_dock_area):
d0 = bec_dock_area.add_dock()
fig = d0.add_widget("BECFigure")
d0 = bec_dock_area.new()
fig = d0.new("BECFigure")
plt = fig.plot(x_name="samx", y_name="bpm4i")
im = fig.image("eiger")
mm = fig.motor_map("samx", "samy")
mw = fig.multi_waveform("waveform1d")
assert len(bec_dock_area.dock_area.docks) == 1
assert len(d0.widgets) == 1
assert len(d0.widget_list) == 1
assert len(d0.elements) == 1
assert len(d0.element_list) == 1
assert len(fig.widgets) == 4
assert fig.config.widget_class == "BECFigure"
@@ -78,20 +78,20 @@ def test_add_remove_bec_figure_to_dock(bec_dock_area):
def test_close_docks(bec_dock_area, qtbot):
d0 = bec_dock_area.add_dock(name="dock_0")
d1 = bec_dock_area.add_dock(name="dock_1")
d2 = bec_dock_area.add_dock(name="dock_2")
d0 = bec_dock_area.new(name="dock_0")
d1 = bec_dock_area.new(name="dock_1")
d2 = bec_dock_area.new(name="dock_2")
bec_dock_area.clear_all()
bec_dock_area.delete_all()
qtbot.wait(200)
assert len(bec_dock_area.dock_area.docks) == 0
def test_undock_and_dock_docks(bec_dock_area, qtbot):
d0 = bec_dock_area.add_dock(name="dock_0")
d1 = bec_dock_area.add_dock(name="dock_1")
d2 = bec_dock_area.add_dock(name="dock_4")
d3 = bec_dock_area.add_dock(name="dock_3")
d0 = bec_dock_area.new(name="dock_0")
d1 = bec_dock_area.new(name="dock_1")
d2 = bec_dock_area.new(name="dock_4")
d3 = bec_dock_area.new(name="dock_3")
d0.detach()
bec_dock_area.detach_dock("dock_1")
@@ -114,28 +114,44 @@ def test_undock_and_dock_docks(bec_dock_area, qtbot):
###################################
def test_toolbar_add_plot_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["waveform"].trigger()
assert "waveform_1" in bec_dock_area.panels
assert bec_dock_area.panels["waveform_1"].widgets[0].config.widget_class == "BECWaveformWidget"
assert "Waveform_0" in bec_dock_area.panels
assert bec_dock_area.panels["Waveform_0"].widgets[0].config.widget_class == "Waveform"
def test_toolbar_add_plot_scatter_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["scatter_waveform"].trigger()
assert "ScatterWaveform_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["ScatterWaveform_0"].widgets[0].config.widget_class
== "ScatterWaveform"
)
def test_toolbar_add_plot_image(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["image"].trigger()
assert "image_1" in bec_dock_area.panels
assert bec_dock_area.panels["image_1"].widgets[0].config.widget_class == "BECImageWidget"
assert "Image_0" in bec_dock_area.panels
assert bec_dock_area.panels["Image_0"].widgets[0].config.widget_class == "Image"
def test_toolbar_add_plot_motor_map(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["motor_map"].trigger()
assert "motor_map_1" in bec_dock_area.panels
assert bec_dock_area.panels["motor_map_1"].widgets[0].config.widget_class == "BECMotorMapWidget"
assert "MotorMap_0" in bec_dock_area.panels
assert bec_dock_area.panels["MotorMap_0"].widgets[0].config.widget_class == "MotorMap"
def test_toolbar_add_multi_waveform(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_plots"].widgets["multi_waveform"].trigger()
assert "BECMultiWaveformWidget_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["BECMultiWaveformWidget_0"].widgets[0].config.widget_class
== "BECMultiWaveformWidget"
)
def test_toolbar_add_device_positioner_box(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_devices"].widgets["positioner_box"].trigger()
assert "positioner_box_1" in bec_dock_area.panels
assert (
bec_dock_area.panels["positioner_box_1"].widgets[0].config.widget_class == "PositionerBox"
)
assert "PositionerBox_0" in bec_dock_area.panels
assert bec_dock_area.panels["PositionerBox_0"].widgets[0].config.widget_class == "PositionerBox"
def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
@@ -143,19 +159,20 @@ def test_toolbar_add_utils_queue(bec_dock_area, bec_queue_msg_full):
MessageEndpoints.scan_queue_status(), bec_queue_msg_full
)
bec_dock_area.toolbar.widgets["menu_utils"].widgets["queue"].trigger()
assert "queue_1" in bec_dock_area.panels
assert bec_dock_area.panels["queue_1"].widgets[0].config.widget_class == "BECQueue"
assert "BECQueue_0" in bec_dock_area.panels
assert bec_dock_area.panels["BECQueue_0"].widgets[0].config.widget_class == "BECQueue"
def test_toolbar_add_utils_status(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_utils"].widgets["status"].trigger()
assert "status_1" in bec_dock_area.panels
assert bec_dock_area.panels["status_1"].widgets[0].config.widget_class == "BECStatusBox"
assert "BECStatusBox_0" in bec_dock_area.panels
assert bec_dock_area.panels["BECStatusBox_0"].widgets[0].config.widget_class == "BECStatusBox"
def test_toolbar_add_utils_progress_bar(bec_dock_area):
bec_dock_area.toolbar.widgets["menu_utils"].widgets["progress_bar"].trigger()
assert "progress_bar_1" in bec_dock_area.panels
assert "RingProgressBar_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["progress_bar_1"].widgets[0].config.widget_class == "RingProgressBar"
bec_dock_area.panels["RingProgressBar_0"].widgets[0].config.widget_class
== "RingProgressBar"
)

View File

@@ -1,224 +0,0 @@
from unittest.mock import MagicMock
import pyqtgraph as pg
import pytest
from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from .client_mocks import mocked_client
@pytest.fixture
def image_widget(qtbot, mocked_client):
widget = BECImageWidget(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def mock_image(image_widget):
image_mock = MagicMock()
image_widget._image = image_mock
return image_mock
def test_image_widget_init(image_widget):
assert image_widget is not None
assert image_widget.client is not None
assert isinstance(image_widget, BECImageWidget)
assert image_widget.config.widget_class == "BECImageWidget"
assert image_widget._image is not None
assert (
BECDeviceFilter.DEVICE
in image_widget.toolbar.widgets["monitor"].device_combobox.config.device_filter
)
assert image_widget.toolbar.widgets["drag_mode"].action.isChecked() == True
assert image_widget.toolbar.widgets["rectangle_mode"].action.isChecked() == False
assert image_widget.toolbar.widgets["auto_range"].action.isChecked() == False
assert image_widget.toolbar.widgets["auto_range_image"].action.isChecked() == True
assert image_widget.toolbar.widgets["FFT"].action.isChecked() == False
assert image_widget.toolbar.widgets["transpose"].action.isChecked() == False
assert image_widget.toolbar.widgets["log"].action.isChecked() == False
###################################
# Toolbar Actions
###################################
def test_toolbar_connect_action(image_widget, mock_image, qtbot):
combo_device = image_widget.toolbar.widgets["monitor"].device_combobox
combo_device.setCurrentText("eiger")
qtbot.wait(200)
assert combo_device.currentText() == "eiger"
combo_dim = image_widget.toolbar.widgets["monitor_type"].widget
combo_dim.setCurrentText("2d")
qtbot.wait(200)
assert combo_dim.currentText() == "2d"
action = image_widget.toolbar.widgets["connect"].action
action.trigger()
image_widget._image.image.assert_called_once_with(
monitor="eiger",
monitor_type="2d",
color_map="magma",
color_bar="full",
downsample=True,
opacity=1.0,
vrange=None,
)
def test_image_toolbar_drag_mode_action_triggered(image_widget, qtbot):
action_drag = image_widget.toolbar.widgets["drag_mode"].action
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
action_drag.trigger()
assert action_drag.isChecked() == True
assert action_rectangle.isChecked() == False
def test_image_toolbar_rectangle_mode_action_triggered(image_widget, qtbot):
action_drag = image_widget.toolbar.widgets["drag_mode"].action
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
action_rectangle.trigger()
assert action_drag.isChecked() == False
assert action_rectangle.isChecked() == True
def test_image_toolbar_auto_range(image_widget, mock_image):
action = image_widget.toolbar.widgets["auto_range"].action
action.trigger()
image_widget._image.set_auto_range.assert_called_once_with(True, "xy")
def test_image_toolbar_enable_mouse_pan_mode(qtbot, image_widget):
action_drag = image_widget.toolbar.widgets["drag_mode"].action
action_rectangle = image_widget.toolbar.widgets["rectangle_mode"].action
mock_view_box = MagicMock()
image_widget._image.plot_item.getViewBox = MagicMock(return_value=mock_view_box)
image_widget.enable_mouse_pan_mode()
assert action_drag.isChecked() == True
assert action_rectangle.isChecked() == False
mock_view_box.setMouseMode.assert_called_once_with(pg.ViewBox.PanMode)
def test_image_toolbar_auto_range_image(image_widget, mock_image):
action = image_widget.toolbar.widgets["auto_range_image"].action
action.trigger()
assert action.isChecked() == False
image_widget._image.set_autorange.assert_called_once_with(False)
def test_image_toolbar_FFT(image_widget, mock_image):
action = image_widget.toolbar.widgets["FFT"].action
action.trigger()
assert action.isChecked() == True
image_widget._image.set_fft.assert_called_once_with(True, None)
def test_image_toolbar_log(image_widget, mock_image):
action = image_widget.toolbar.widgets["log"].action
action.trigger()
assert action.isChecked() == True
image_widget._image.set_log.assert_called_once_with(True, None)
def test_image_toggle_transpose(image_widget, mock_image):
action = image_widget.toolbar.widgets["transpose"].action
action.trigger()
assert action.isChecked() == True
image_widget._image.set_transpose.assert_called_once_with(True, None)
def test_image_toolbar_rotation(image_widget, mock_image):
action_left = image_widget.toolbar.widgets["rotate_left"].action
action_right = image_widget.toolbar.widgets["rotate_right"].action
action_left.trigger()
image_widget._image.set_rotation(1, None)
action_right.trigger()
image_widget._image.set_rotation(2, None)
action_right.trigger()
image_widget._image.set_rotation(1, None)
###################################
# Wrapper methods for ImageShow
###################################
def test_image_set_image(image_widget, mock_image):
image_widget.image(monitor="image", monitor_type="2d")
image_widget._image.image.assert_called_once_with(
monitor="image",
monitor_type="2d",
color_map="magma",
color_bar="full",
downsample=True,
opacity=1.0,
vrange=None,
)
def test_image_vrange(image_widget, mock_image):
image_widget.set_vrange(0, 1)
image_widget._image.set_vrange.assert_called_once_with(0, 1, None)
def test_image_set_color_map(image_widget, mock_image):
image_widget.set_color_map("viridis")
image_widget._image.set_color_map.assert_called_once_with("viridis", None)
def test_image_widget_set_title(image_widget, mock_image):
image_widget.set_title("Title Label")
image_widget._image.set_title.assert_called_once_with("Title Label")
def test_image_widget_set_x_label(image_widget, mock_image):
image_widget.set_x_label("X Label")
image_widget._image.set_x_label.assert_called_once_with("X Label")
def test_image_widget_set_y_label(image_widget, mock_image):
image_widget.set_y_label("Y Label")
image_widget._image.set_y_label.assert_called_once_with("Y Label")
def test_image_widget_set_x_scale(image_widget, mock_image):
image_widget.set_x_scale("linear")
image_widget._image.set_x_scale.assert_called_once_with("linear")
def test_image_widget_set_y_scale(image_widget, mock_image):
image_widget.set_y_scale("log")
image_widget._image.set_y_scale.assert_called_once_with("log")
def test_image_widget_set_x_lim(image_widget, mock_image):
image_widget.set_x_lim((0, 10))
image_widget._image.set_x_lim.assert_called_once_with((0, 10))
def test_image_widget_set_y_lim(image_widget, mock_image):
image_widget.set_y_lim((0, 10))
image_widget._image.set_y_lim.assert_called_once_with((0, 10))
def test_image_widget_set_grid(image_widget, mock_image):
image_widget.set_grid(True, False)
image_widget._image.set_grid.assert_called_once_with(True, False)
def test_image_widget_lock_aspect_ratio(image_widget, mock_image):
image_widget.lock_aspect_ratio(True)
image_widget._image.lock_aspect_ratio.assert_called_once_with(True)
def test_image_widget_export(image_widget, mock_image):
image_widget.export()
image_widget._image.export.assert_called_once()

View File

@@ -12,7 +12,7 @@ from bec_widgets.tests.utils import FakeDevice
def cli_figure():
fig = BECFigure(gui_id="test")
with mock.patch.object(fig, "_run_rpc") as mock_rpc_call:
with mock.patch.object(fig, "gui_is_alive", return_value=True):
with mock.patch.object(fig, "_gui_is_alive", return_value=True):
yield fig, mock_rpc_call
@@ -40,8 +40,17 @@ def test_rpc_call_accepts_device_as_input(cli_figure):
)
def test_client_utils_start_plot_process(config, call_config):
with mock.patch("bec_widgets.cli.client_utils.subprocess.Popen") as mock_popen:
_start_plot_process("gui_id", BECFigure, config)
command = ["bec-gui-server", "--id", "gui_id", "--gui_class", "BECFigure", "--hide"]
_start_plot_process("gui_id", BECFigure, "bec", config)
command = [
"bec-gui-server",
"--id",
"gui_id",
"--gui_class",
"BECFigure",
"--gui_class_id",
"bec",
"--hide",
]
if call_config:
command.extend(["--config", call_config])
mock_popen.assert_called_once_with(
@@ -66,20 +75,24 @@ def test_client_utils_passes_client_config_to_server(bec_dispatcher):
mixin = BECGuiClient()
mixin._client = bec_dispatcher.client
mixin._gui_id = "gui_id"
mixin.gui_is_alive = mock.MagicMock()
mixin.gui_is_alive.side_effect = [True]
mixin._gui_is_alive = mock.MagicMock()
mixin._gui_is_alive.side_effect = [True]
try:
yield mixin
finally:
mixin.close()
mixin.kill_server()
with bec_client_mixin() as mixin:
with mock.patch("bec_widgets.cli.client_utils._start_plot_process") as mock_start_plot:
mock_start_plot.return_value = [mock.MagicMock(), mock.MagicMock()]
mixin.start_server(
mixin._start_server(
wait=False
) # the started event will not be set, wait=True would block forever
mock_start_plot.assert_called_once_with(
"gui_id", BECGuiClient, mixin._client._service_config.config, logger=mock.ANY
"gui_id",
BECGuiClient,
gui_class_id="bec",
config=mixin._client._service_config.config,
logger=mock.ANY,
)

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