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

Compare commits

...

21 Commits

Author SHA1 Message Date
3653fe9799 wip - fix formatter 2025-03-13 10:32:04 +01:00
750350dc52 wip - removed rpcreference import 2025-03-13 10:30:04 +01:00
9eb1608b01 wip - test namespace 2025-03-13 10:24:35 +01:00
0433b40054 wip - namespace 2025-03-13 10:24:35 +01:00
d1a41752c4 wip - namespace 2025-03-13 10:24:35 +01:00
bf060b3aba wip - namespace 2025-03-13 10:24:35 +01:00
5f0dd62f25 wip - namespace 2025-03-13 10:24:35 +01:00
21b1f0b2de wip - namespace 2025-03-13 10:24:35 +01:00
17f6dbb0d4 wip - namespace 2025-03-13 10:24:35 +01:00
ba347e026a wip - namespace update 2025-03-13 10:06:11 +01:00
705f157c04 docs(plot_base): update docstrings for properties and setters 2025-03-06 16:07:56 +01:00
4736c2fad1 refactor(waveform_widget): removed and replaced by Waveform 2025-03-06 16:07:56 +01:00
31b40aeede test(plot_indicators): tests adapted to not be dependent on BECWaveformWidget 2025-03-06 16:07:56 +01:00
77e8a5c884 fix(plot_indicators): cleanup adjusted 2025-03-06 16:07:56 +01:00
0f4365bbb0 feat(waveform): new Waveform widget based on NextGen PlotBase 2025-03-06 16:07:56 +01:00
906ca03929 fix(entry_validator): validator reports list of signal if user chooses the wrong one 2025-03-06 16:07:56 +01:00
1206069a8f fix(plot_base): update mouse mode state on mode change 2025-03-06 16:07:56 +01:00
86487a5f4d fix(plot_base): aspect ratio removed from the PlotBase 2025-03-06 16:07:56 +01:00
4bdcae7028 fix(plot_base): inner and outer axis setting in popup mode 2025-03-06 16:07:56 +01:00
81f61f3c3b fix(plot_base): fix cleanup of popups if popups are still open when PlotBase is closed 2025-03-06 16:07:56 +01:00
89e8ebf1b6 fix(lmfit_dialog_vertical): vertical sizePolicy fixed 2025-03-06 16:07:56 +01:00
57 changed files with 5856 additions and 3205 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
@@ -7,13 +9,15 @@ import os
import select
import subprocess
import threading
import time
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 +27,17 @@ 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 +72,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
) -> None:
"""
Start the plot in a new process.
@@ -76,7 +83,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 +127,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,42 +158,63 @@ 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):
"""BEC GUI client class. Container for GUI applications within Python."""
_top_level = {}
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()
@@ -181,14 +222,21 @@ class BECGuiClient(RPCBase):
self._process_output_processing_thread = None
@property
def windows(self):
def windows(self) -> dict:
"""Dictionary with dock ares 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,51 +247,53 @@ 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(
@@ -263,7 +313,7 @@ class BECGuiClient(RPCBase):
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 +322,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 +337,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()
@@ -299,49 +353,91 @@ class BECGuiClient(RPCBase):
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
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
return widget
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()

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:
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,11 +68,25 @@ 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
def get_rpc_by_name(self, name: str) -> QObject | None:
"""
Get an RPC object by its name.
Args:
name(str): The name of the RPC object to be retrieved.
Returns:
QObject | None: The RPC object with the given name.
"""
rpc_object = [rpc for rpc in self._rpc_register if rpc._name == name]
rpc_object = rpc_object[0] if len(rpc_object) > 0 else None
return rpc_object
def list_all_connections(self) -> dict:
"""
List all the registered RPC objects.
@@ -73,6 +98,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, Any]:
"""
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,27 @@ 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

@@ -15,6 +15,7 @@ from bec_lib.utils.import_utils import lazy_import
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
@@ -36,6 +37,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,14 +59,15 @@ class BECWidgetsCLIServer:
dispatcher: BECDispatcher = None,
client=None,
config=None,
gui_class: Union[BECFigure, BECDockArea] = BECFigure,
gui_class: Union[BECFigure, BECDockArea] = BECDockArea,
gui_class_id: str = "bec",
) -> None:
self.status = messages.BECStatus.BUSY
self.dispatcher = BECDispatcher(config=config) if dispatcher is None else dispatcher
self.client = self.dispatcher.client if client is None else client
self.client.start()
self.gui_id = gui_id
self.gui = gui_class(gui_id=self.gui_id)
# register broadcast callback
self.rpc_register = RPCRegister()
self.rpc_register.add_rpc(self.gui)
@@ -78,6 +82,8 @@ class BECWidgetsCLIServer:
self.status = messages.BECStatus.RUNNING
logger.success(f"Server started with gui_id: {self.gui_id}")
# Create initial object -> BECFigure or BECDockArea
self.gui = gui_class(parent=None, name=gui_class_id)
def on_rpc_update(self, msg: dict, metadata: dict):
request_id = metadata.get("request_id")
@@ -135,6 +141,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 +188,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],
gui_class_id: str = "bec",
config: str | None = None,
):
if config:
try:
config = json.loads(config)
@@ -196,7 +210,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 +233,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")
@@ -256,14 +278,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

@@ -21,6 +21,7 @@ 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.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
class JupyterConsoleWindow(QWidget): # pragma: no cover:
@@ -51,8 +52,6 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"w10": self.w10,
"d0": self.d0,
"d1": self.d1,
"d2": self.d2,
"wave": self.wf,
"im": self.im,
"mm": self.mm,
"mw": self.mw,
@@ -65,6 +64,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"btn6": self.btn6,
"pb": self.pb,
"pi": self.pi,
"wf": self.wf,
}
)
@@ -100,7 +100,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,6 +117,15 @@ 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)
# add stuff to the new Waveform widget
self._init_waveform()
# add stuff to figure
self._init_figure()
@@ -125,6 +134,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
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(
@@ -182,18 +198,19 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
def _init_dock(self):
self.d0 = self.dock.add_dock(name="dock_0")
self.mm = self.d0.add_widget("BECMotorMapWidget")
self.d0 = self.dock.new(name="dock_0")
self.mm = self.d0.new("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.d1 = self.dock.new(name="dock_1", position="right")
self.im = self.d1.new("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.d2 = self.dock.new(name="dock_2", position="bottom")
self.wf = self.d2.new("BECFigure", row=0, col=0)
self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
self.mw = None # self.wf.multi_waveform(monitor="waveform") # , config=config)
self.dock.save_state()
@@ -219,7 +236,7 @@ if __name__ == "__main__": # pragma: no cover
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()

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

@@ -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:
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,15 @@ 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
# Create a default name if None is provided
if name is None:
name = "bec_widget_init_without_name"
# name = self.__class__.__name__
# Check for invalid chars in the name
if not WidgetContainerUtils.has_name_valid_chars(name):
raise ValueError(f"Name {name} contains invalid characters.")
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 +104,13 @@ class BECWidget(BECConnector):
def cleanup(self):
"""Cleanup the widget."""
# needed here instead of closeEvent, to be checked why
# However, 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

@@ -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__":
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,
@@ -26,13 +28,15 @@ 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.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 +48,19 @@ 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",
"new",
"show",
"hide",
"panels",
"panel_list",
"delete",
"delete_all",
"remove",
"detach_dock",
"attach_all",
"selected_device",
"save_state",
"restore_state",
]
def __init__(
@@ -67,6 +69,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 +78,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,9 +94,7 @@ class BECDockArea(BECWidget, QWidget):
label="Add Plot ",
actions={
"waveform": MaterialIconAction(
icon_name=BECWaveformWidget.ICON_NAME,
tooltip="Add Waveform",
filled=True,
icon_name=Waveform.ICON_NAME, tooltip="Add Waveform", filled=True
),
"multi_waveform": MaterialIconAction(
icon_name=BECMultiWaveformWidget.ICON_NAME,
@@ -171,41 +174,41 @@ 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["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="BECImageWidget")
)
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="BECMotorMapWidget")
)
# 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 +216,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 +228,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
@@ -245,7 +253,17 @@ 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 temp_areas(self) -> list:
@@ -289,36 +307,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 +327,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 +340,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 +362,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 +404,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 +442,52 @@ 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 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

@@ -1,12 +1,17 @@
from bec_lib.logger import bec_logger
from qtpy.QtWidgets import QApplication, QMainWindow
from bec_widgets.utils import BECConnector
from bec_widgets.cli.rpc.rpc_register import RPCRegister
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.container_utils import WidgetContainerUtils
from bec_widgets.widgets.containers.dock.dock_area import BECDockArea
logger = bec_logger.logger
class BECMainWindow(QMainWindow, BECConnector):
def __init__(self, *args, **kwargs):
BECConnector.__init__(self, **kwargs)
class BECMainWindow(BECWidget, QMainWindow):
def __init__(self, gui_id: str = None, *args, **kwargs):
BECWidget.__init__(self, gui_id=gui_id, **kwargs)
QMainWindow.__init__(self, *args, **kwargs)
def _dump(self):
@@ -33,9 +38,38 @@ class BECMainWindow(QMainWindow, BECConnector):
}
return info
def new_dock_area(self, name):
dock_area = BECDockArea()
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)
dock_area = BECDockArea(name=name)
dock_area.resize(dock_area.minimumSizeHint())
dock_area.window().setWindowTitle(name)
# TODO Should we simply use the specified name as title here?
dock_area.window().setWindowTitle(f"BEC - {name}")
logger.info(f"Created new dock area: {name}")
logger.info(f"Existing dock areas: {geometry}")
if geometry is not None:
dock_area.setGeometry(*geometry)
dock_area.show()
return dock_area
def cleanup(self):
# TODO
super().close()

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': ['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

@@ -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)
@@ -218,15 +219,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 +266,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 +287,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,10 +308,19 @@ 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):
"""
Show Toolbar.
Args:
value(bool): The value to set.
"""
if value:
# Disable popup mode
if self._popups:
@@ -299,10 +340,19 @@ class PlotBase(BECWidget, QWidget):
@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:
@@ -369,19 +419,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)
@@ -420,10 +488,18 @@ class PlotBase(BECWidget, QWidget):
@SafeProperty(str, doc="The text of the y label")
def y_label(self) -> str:
"""
The set label for the y-axis.
"""
return self.plot_item.getAxis("left").labelText
@y_label.setter
def y_label(self, value: str):
"""
The set label for the y-axis.
Args:
value(str): The label to set.
"""
self.plot_item.setLabel("left", text=value)
self.property_changed.emit("y_label", value)
@@ -452,37 +528,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 +604,241 @@ 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.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 +846,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,8 +938,11 @@ 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):
"""Cleanup pyqtgraph items."""

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)
@@ -132,8 +131,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 +141,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

@@ -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

@@ -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.waveform.waveform_plugin import WaveformPlugin
QPyDesignerCustomWidgetCollection.addCustomWidget(BECWaveformWidgetPlugin())
QPyDesignerCustomWidgetCollection.addCustomWidget(WaveformPlugin())
if __name__ == "__main__": # pragma: no cover

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

@@ -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.waveform.waveform import Waveform
DOM_XML = """
<ui language='c++'>
<widget class='BECWaveformWidget' name='bec_waveform_widget'>
<widget class='Waveform' name='waveform'>
</widget>
</ui>
"""
MODULE_PATH = os.path.dirname(bec_widgets.__file__)
class BECWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
class WaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
def __init__(self):
super().__init__()
self._form_editor = None
def createWidget(self, parent):
t = BECWaveformWidget(parent)
t = Waveform(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(Waveform.ICON_NAME)
def includeFile(self):
return "bec_waveform_widget"
return "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 "Waveform"
def toolTip(self):
return "BECWaveformWidget"
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,14 +1,24 @@
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.bec.new("dock")
fig = dock.new(name="fig", widget="BECFigure")
return fig
def test_rpc_waveform1d_custom_curve(connected_figure):
fig = connected_figure
# fig = BECFigure(connected_client_figure)
ax = fig.plot()
curve = ax.plot(x=[1, 2, 3], y=[1, 2, 3])
@@ -20,8 +30,9 @@ 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
# fig = BECFigure(connected_client_figure)
plt = fig.plot(x_name="samx", y_name="bpm4i")
im = fig.image("eiger")
@@ -78,9 +89,9 @@ 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 = BECFigure(connected_client_figure)
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 +125,9 @@ 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 = BECFigure(connected_client_figure)
fig = connected_figure
im = fig.image("eiger")
@@ -135,8 +147,9 @@ 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 = BECFigure(connected_client_figure)
fig = connected_figure
motor_map = fig.motor_map("samx", "samy")
@@ -164,9 +177,10 @@ 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
# fig = BECFigure(connected_client_figure)
plt = fig.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
client = bec_client_lib
@@ -204,8 +218,9 @@ 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 = BECFigure(connected_client_figure)
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.bec.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,150 @@ 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

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,31 @@ 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_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 "BECImageWidget_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["BECImageWidget_0"].widgets[0].config.widget_class == "BECImageWidget"
)
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 "BECMotorMapWidget_0" in bec_dock_area.panels
assert (
bec_dock_area.panels["BECMotorMapWidget_0"].widgets[0].config.widget_class
== "BECMotorMapWidget"
)
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 +146,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

@@ -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,
)

View File

@@ -1,9 +1,10 @@
import numpy as np
import pyqtgraph as pg
import pytest
from qtpy.QtCore import QPointF, Qt
from bec_widgets.utils import Crosshair
from bec_widgets.widgets.plots.image.image_widget import BECImageWidget
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from .client_mocks import mocked_client
@@ -11,14 +12,16 @@ from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_crosshair(qtbot, mocked_client):
widget = BECWaveformWidget(client=mocked_client())
widget.plot(x=[1, 2, 3], y=[4, 5, 6])
widget.waveform.hook_crosshair()
def plot_widget_with_crosshair(qtbot):
widget = pg.PlotWidget()
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget.waveform.crosshair, widget.waveform.plot_item
widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1")
plot_item = widget.getPlotItem()
crosshair = Crosshair(plot_item=plot_item, precision=3)
yield crosshair, plot_item
@pytest.fixture
@@ -35,15 +38,14 @@ def image_widget_with_crosshair(qtbot, mocked_client):
def test_mouse_moved_lines(plot_widget_with_crosshair):
crosshair, plot_item = plot_widget_with_crosshair
# Simulate a mouse moved event at a specific position
pos_in_view = QPointF(2, 5)
pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view)
event_mock = [pos_in_scene]
# Call the mouse_moved method
# Simulate mouse movement
crosshair.mouse_moved(event_mock)
# Assert the expected behavior
# Check that the vertical line is indeed at x=2
assert np.isclose(crosshair.v_line.pos().x(), 2)
assert np.isclose(crosshair.h_line.pos().y(), 5)

View File

@@ -0,0 +1,367 @@
import json
from unittest.mock import MagicMock, patch
import pytest
from qtpy.QtWidgets import QComboBox, QVBoxLayout
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_setting import (
CurveSetting,
)
from bec_widgets.widgets.plots_next_gen.waveform.settings.curve_settings.curve_tree import CurveTree
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from tests.unit_tests.conftest import create_widget
##################################################
# CurveSetting
##################################################
@pytest.fixture
def curve_setting_fixture(qtbot, mocked_client):
"""
Creates a CurveSetting widget targeting a mock or real Waveform widget.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "auto"
curve_setting = create_widget(qtbot, CurveSetting, parent=None, target_widget=wf)
return curve_setting, wf
def test_curve_setting_init(curve_setting_fixture):
"""
Ensure CurveSetting constructs properly, with a CurveTree inside
and an x-axis group box for modes.
"""
curve_setting, wf = curve_setting_fixture
# Basic checks
assert curve_setting.objectName() == "CurveSetting"
# The layout should be QVBoxLayout
assert isinstance(curve_setting.layout, QVBoxLayout)
# There's an x_axis_box group and a y_axis_box group
assert hasattr(curve_setting, "x_axis_box")
assert hasattr(curve_setting, "y_axis_box")
# The x_axis_box should contain a QComboBox for mode
mode_combo = curve_setting.mode_combo
assert isinstance(mode_combo, QComboBox)
# Should contain these items: ["auto", "index", "timestamp", "device"]
expected_modes = ["auto", "index", "timestamp", "device"]
for m in expected_modes:
assert m in [
curve_setting.mode_combo.itemText(i) for i in range(curve_setting.mode_combo.count())
]
# Check that there's a curve_manager inside y_axis_box
assert hasattr(curve_setting, "curve_manager")
assert curve_setting.y_axis_box.layout.count() > 0
def test_curve_setting_accept_changes(curve_setting_fixture, qtbot):
"""
Test that calling accept_changes() applies x-axis mode changes
and triggers the CurveTree to send its curve JSON to the target waveform.
"""
curve_setting, wf = curve_setting_fixture
# Suppose user chooses "index" from the combo
curve_setting.mode_combo.setCurrentText("index")
# The device_x is disabled if not device mode
# Spy on 'send_curve_json' from the curve_manager
send_spy = MagicMock()
curve_setting.curve_manager.send_curve_json = send_spy
# Call accept_changes()
curve_setting.accept_changes()
# Check that we updated the waveform
assert wf.x_mode == "index"
# Check that the manager send_curve_json was called
send_spy.assert_called_once()
def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot):
"""
If user chooses device mode from the combo, the device_x line edit should be enabled
and set to the current wavefrom.x_axis_mode["name"].
"""
curve_setting, wf = curve_setting_fixture
# Initially we assume "auto"
assert curve_setting.mode_combo.currentText() == "auto"
# Switch to device
curve_setting.mode_combo.setCurrentText("device")
assert curve_setting.device_x.isEnabled()
# This line edit should reflect the waveform.x_axis_mode["name"], or be blank if none
assert curve_setting.device_x.text() == wf.x_axis_mode["name"]
def test_curve_setting_refresh(curve_setting_fixture, qtbot):
"""
Test that calling refresh() refreshes the embedded CurveTree
and re-reads the x axis mode from the waveform.
"""
curve_setting, wf = curve_setting_fixture
# Suppose the waveform changed x_mode from "auto" to "timestamp" behind the scenes
wf.x_mode = "timestamp"
# Spy on the curve_manager
refresh_spy = MagicMock()
curve_setting.curve_manager.refresh_from_waveform = refresh_spy
# Call refresh
curve_setting.refresh()
refresh_spy.assert_called_once()
# The combo should now read "timestamp"
assert curve_setting.mode_combo.currentText() == "timestamp"
##################################################
# CurveTree
##################################################
@pytest.fixture
def curve_tree_fixture(qtbot, mocked_client_with_dap):
"""
Creates a CurveTree widget referencing a mocked or real Waveform.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.color_palette = "magma"
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
return curve_tree, wf
def test_curve_tree_init(curve_tree_fixture):
"""
Test that the CurveTree initializes properly with references to the waveform,
sets up the toolbar, and an empty QTreeWidget.
"""
curve_tree, wf = curve_tree_fixture
assert curve_tree.waveform == wf
assert curve_tree.color_palette == "magma"
assert curve_tree.tree.columnCount() == 7
assert "add" in curve_tree.toolbar.widgets
assert "expand_all" in curve_tree.toolbar.widgets
assert "collapse_all" in curve_tree.toolbar.widgets
assert "renormalize_colors" in curve_tree.toolbar.widgets
def test_add_new_curve(curve_tree_fixture):
"""
Test that add_new_curve() adds a top-level item with a device curve config,
assigns it a color from the buffer, and doesn't modify existing rows.
"""
curve_tree, wf = curve_tree_fixture
curve_tree.color_buffer = ["#111111", "#222222", "#333333", "#444444", "#555555"]
assert curve_tree.tree.topLevelItemCount() == 0
with patch.object(curve_tree, "_ensure_color_buffer_size") as ensure_spy:
new_item = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
ensure_spy.assert_called_once()
assert curve_tree.tree.topLevelItemCount() == 1
last_item = curve_tree.all_items[-1]
assert last_item is new_item
assert new_item.config.source == "device"
assert new_item.config.signal.name == "bpm4i"
assert new_item.config.signal.entry == "bpm4i"
assert new_item.config.color in curve_tree.color_buffer
def test_renormalize_colors(curve_tree_fixture):
"""
Test that renormalize_colors overwrites colors for all items in creation order.
"""
curve_tree, wf = curve_tree_fixture
# Add multiple curves
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
c2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
curve_tree.color_buffer = []
set_color_spy_c1 = patch.object(c1.color_button, "set_color")
set_color_spy_c2 = patch.object(c2.color_button, "set_color")
with set_color_spy_c1 as spy1, set_color_spy_c2 as spy2:
curve_tree.renormalize_colors()
spy1.assert_called_once()
spy2.assert_called_once()
def test_expand_collapse(curve_tree_fixture):
"""
Test expand_all_daps() and collapse_all_daps() calls expand/collapse on every top-level item.
"""
curve_tree, wf = curve_tree_fixture
c1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
curve_tree.tree.expandAll()
expand_spy = patch.object(curve_tree.tree, "expandItem")
collapse_spy = patch.object(curve_tree.tree, "collapseItem")
with expand_spy as e_spy:
curve_tree.expand_all_daps()
e_spy.assert_called_once_with(c1)
with collapse_spy as c_spy:
curve_tree.collapse_all_daps()
c_spy.assert_called_once_with(c1)
def test_send_curve_json(curve_tree_fixture, monkeypatch):
"""
Test that send_curve_json sets the waveform's color_palette and curve_json
to the exported config from the tree.
"""
curve_tree, wf = curve_tree_fixture
# Add multiple curves
curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
curve_tree.color_palette = "viridis"
curve_tree.send_curve_json()
assert wf.color_palette == "viridis"
data = json.loads(wf.curve_json)
assert len(data) == 2
labels = [d["label"] for d in data]
assert "bpm4i-bpm4i" in labels
assert "bpm3a-bpm3a" in labels
def test_refresh_from_waveform(qtbot, mocked_client_with_dap, monkeypatch):
"""
Test that refresh_from_waveform() rebuilds the tree from the waveform's curve_json
"""
patched_models = {"GaussianModel": {}, "LorentzModel": {}, "SineModel": {}}
monkeypatch.setattr(mocked_client_with_dap.dap, "_available_dap_plugins", patched_models)
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.x_mode = "auto"
curve_tree = create_widget(qtbot, CurveTree, parent=None, waveform=wf)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.plot(arg1="bpm3a", dap="GaussianModel")
# Clear the tree to simulate a fresh rebuild.
curve_tree.tree.clear()
curve_tree.all_items.clear()
assert curve_tree.tree.topLevelItemCount() == 0
# For DAP rows
curve_tree.refresh_from_waveform()
assert curve_tree.tree.topLevelItemCount() == 2
def test_add_dap_row(curve_tree_fixture):
"""
Test that add_dap_row creates a new DAP curve as a child of a device curve,
with the correct configuration and parent-child relationship.
"""
curve_tree, wf = curve_tree_fixture
# Add a device curve first
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
assert device_row.source == "device"
assert curve_tree.tree.topLevelItemCount() == 1
assert device_row.childCount() == 0
# Now add a DAP row to it
device_row.add_dap_row()
# Check that child was added
assert device_row.childCount() == 1
dap_child = device_row.child(0)
# Verify the DAP child has the correct configuration
assert dap_child.source == "dap"
assert dap_child.config.parent_label == device_row.config.label
# Check that the DAP inherits device name/entry from parent
assert dap_child.config.signal.name == "bpm4i"
assert dap_child.config.signal.entry == "bpm4i"
# Check that the item is in the curve_tree's all_items list
assert dap_child in curve_tree.all_items
def test_remove_self_top_level(curve_tree_fixture):
"""
Test that remove_self removes a top-level device row from the tree.
"""
curve_tree, wf = curve_tree_fixture
# Add two device curves
row1 = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
row2 = curve_tree.add_new_curve(name="bpm3a", entry="bpm3a")
assert curve_tree.tree.topLevelItemCount() == 2
assert len(curve_tree.all_items) == 2
# Remove the first row
row1.remove_self()
# Check that only one row remains and it's the correct one
assert curve_tree.tree.topLevelItemCount() == 1
assert curve_tree.tree.topLevelItem(0) == row2
assert len(curve_tree.all_items) == 1
assert curve_tree.all_items[0] == row2
def test_remove_self_child(curve_tree_fixture):
"""
Test that remove_self removes a child DAP row while preserving the parent device row.
"""
curve_tree, wf = curve_tree_fixture
# Add a device curve and a DAP child
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
device_row.add_dap_row()
dap_child = device_row.child(0)
assert curve_tree.tree.topLevelItemCount() == 1
assert device_row.childCount() == 1
assert len(curve_tree.all_items) == 2
# Remove the DAP child
dap_child.remove_self()
# Check that the parent device row still exists but has no children
assert curve_tree.tree.topLevelItemCount() == 1
assert device_row.childCount() == 0
assert len(curve_tree.all_items) == 1
assert curve_tree.all_items[0] == device_row
def test_export_data_dap(curve_tree_fixture):
"""
Test that export_data from a DAP row correctly includes parent relationship and DAP model.
"""
curve_tree, wf = curve_tree_fixture
# Add a device curve with specific parameters
device_row = curve_tree.add_new_curve(name="bpm4i", entry="bpm4i")
device_row.config.label = "bpm4i-main"
# Add a DAP child
device_row.add_dap_row()
dap_child = device_row.child(0)
# Set a specific model in the DAP combobox
dap_child.dap_combo.fit_model_combobox.setCurrentText("GaussianModel")
# Export data from the DAP row
exported = dap_child.export_data()
# Check the exported data
assert exported["source"] == "dap"
assert exported["parent_label"] == "bpm4i-main"
assert exported["signal"]["name"] == "bpm4i"
assert exported["signal"]["entry"] == "bpm4i"
assert exported["signal"]["dap"] == "GaussianModel"
assert exported["label"] == "bpm4i-main-GaussianModel"

View File

@@ -14,7 +14,7 @@ def test_init_plot_base(qtbot, mocked_client):
plot_base = bec_figure.add_widget(widget_type="BECPlotBase", widget_id="test_plot")
assert plot_base is not None
assert plot_base.config.widget_class == "BECPlotBase"
assert plot_base.config.gui_id == "test_plot"
assert plot_base.config.gui_id == plot_base.gui_id
def test_plot_base_axes_by_separate_methods(qtbot, mocked_client):

View File

@@ -20,8 +20,10 @@ def test_rpc_server_start_server_without_service_config(mocked_cli_server):
"""
mock_server, mock_config, _ = mocked_cli_server
_start_server("gui_id", BECFigure, None)
mock_server.assert_called_once_with(gui_id="gui_id", config=mock_config(), gui_class=BECFigure)
_start_server("gui_id", BECFigure, config=None)
mock_server.assert_called_once_with(
gui_id="gui_id", config=mock_config(), gui_class=BECFigure, gui_class_id="bec"
)
@pytest.mark.parametrize(
@@ -37,5 +39,7 @@ def test_rpc_server_start_server_with_service_config(mocked_cli_server, config,
"""
mock_server, mock_config, _ = mocked_cli_server
config = mock_config(**call_config)
_start_server("gui_id", BECFigure, config)
mock_server.assert_called_once_with(gui_id="gui_id", config=config, gui_class=BECFigure)
_start_server("gui_id", BECFigure, config=config)
mock_server.assert_called_once_with(
gui_id="gui_id", config=config, gui_class=BECFigure, gui_class_id="bec"
)

View File

@@ -1,27 +1,29 @@
import pytest
from qtpy.QtCore import QPointF
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from bec_widgets.widgets.containers.figure import BECFigure
from .client_mocks import mocked_client
@pytest.fixture
def plot_widget_with_arrow_item(qtbot, mocked_client):
widget = BECWaveformWidget(client=mocked_client())
widget = BECFigure(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
waveform = widget.plot()
yield widget.waveform.arrow_item, widget.waveform.plot_item
yield waveform.arrow_item, waveform.plot_item
@pytest.fixture
def plot_widget_with_tick_item(qtbot, mocked_client):
widget = BECWaveformWidget(client=mocked_client())
widget = BECFigure(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
waveform = widget.plot()
yield widget.waveform.tick_item, widget.waveform.plot_item
yield waveform.tick_item, waveform.plot_item
def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
@@ -31,6 +33,7 @@ def test_arrow_item_add_to_plot(plot_widget_with_arrow_item):
assert arrow_item.plot_item.items == []
arrow_item.add_to_plot()
assert arrow_item.plot_item.items == [arrow_item.arrow_item]
arrow_item.remove_from_plot()
def test_arrow_item_set_position(plot_widget_with_arrow_item):
@@ -50,6 +53,7 @@ def test_arrow_item_set_position(plot_widget_with_arrow_item):
point = QPointF(2.0, 2.0)
assert arrow_item.arrow_item.pos() == point
assert container == [(1, 1), (2, 2)]
arrow_item.remove_from_plot()
def test_arrow_item_cleanup(plot_widget_with_arrow_item):
@@ -75,6 +79,7 @@ def test_tick_item_add_to_plot(plot_widget_with_tick_item):
pos = tick_item.tick.pos()
new_pos = tick_item.tick_item.mapFromParent(QPointF(pos.x(), new_pos))
assert new_pos.y() == pos.y()
tick_item.remove_from_plot()
def test_tick_item_set_position(plot_widget_with_tick_item):
@@ -93,6 +98,7 @@ def test_tick_item_set_position(plot_widget_with_tick_item):
tick_item.set_position(pos=2)
assert tick_item._pos == 2
assert container == [1.0, 2.0]
tick_item.remove_from_plot()
def test_tick_item_cleanup(plot_widget_with_tick_item):

View File

@@ -0,0 +1,787 @@
import json
from unittest.mock import MagicMock
import numpy as np
import pyqtgraph as pg
import pytest
from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem
from bec_widgets.widgets.plots_next_gen.plot_base import UIMode
from bec_widgets.widgets.plots_next_gen.waveform.curve import DeviceSignal
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
from tests.unit_tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from .conftest import create_widget
##################################################
# Waveform widget base functionality tests
##################################################
def test_waveform_initialization(qtbot, mocked_client):
"""
Test that a new Waveform widget initializes with the correct defaults.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
assert wf.objectName() == "Waveform"
# Inherited from PlotBase
assert wf.title == ""
assert wf.x_label == ""
assert wf.y_label == ""
# No crosshair or FPS monitor by default
assert wf.crosshair is None
assert wf.fps_monitor is None
# No curves initially
assert len(wf.plot_item.curves) == 0
def test_waveform_with_side_menu(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=False)
assert wf.ui_mode == UIMode.SIDE
def test_plot_custom_curve(qtbot, mocked_client):
"""
Test that calling plot with explicit x and y data creates a custom curve.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
assert curve is not None
assert curve.config.source == "custom"
assert curve.config.label == "custom_curve"
x_data, y_data = curve.get_data()
np.testing.assert_array_equal(x_data, np.array([1, 2, 3]))
np.testing.assert_array_equal(y_data, np.array([4, 5, 6]))
def test_plot_single_arg_input_1d(qtbot, mocked_client):
"""
Test that when a single 1D numpy array is passed, the curve is created with
x-data as a generated index.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
data = np.array([10, 20, 30])
curve = wf.plot(data, label="curve_1d")
x_data, y_data = curve.get_data()
np.testing.assert_array_equal(x_data, np.arange(len(data)))
np.testing.assert_array_equal(y_data, data)
def test_plot_single_arg_input_2d(qtbot, mocked_client):
"""
Test that when a single 2D numpy array (N x 2) is passed,
x and y data are extracted from the first and second columns.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
data = np.array([[1, 4], [2, 5], [3, 6]])
curve = wf.plot(data, label="curve_2d")
x_data, y_data = curve.get_data()
np.testing.assert_array_equal(x_data, data[:, 0])
np.testing.assert_array_equal(y_data, data[:, 1])
def test_plot_single_arg_input_sync(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c1 = wf.plot(arg1="bpm4i")
c2 = wf.plot(arg1="bpm3a")
assert c1.config.source == "device"
assert c2.config.source == "device"
assert c1.config.signal == DeviceSignal(name="bpm4i", entry="bpm4i", dap=None)
assert c2.config.signal == DeviceSignal(name="bpm3a", entry="bpm3a", dap=None)
# Check that the curve is added to the plot
assert len(wf.plot_item.curves) == 2
def test_plot_single_arg_input_async(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c1 = wf.plot(arg1="eiger")
c2 = wf.plot(arg1="async_device")
assert c1.config.source == "device"
assert c2.config.source == "device"
assert c1.config.signal == DeviceSignal(name="eiger", entry="eiger", dap=None)
assert c2.config.signal == DeviceSignal(name="async_device", entry="async_device", dap=None)
# Check that the curve is added to the plot
assert len(wf.plot_item.curves) == 2
def test_curve_access_pattern(qtbot, mocked_client):
wf = create_widget(qtbot, Waveform, client=mocked_client)
c1 = wf.plot(arg1="bpm4i")
c2 = wf.plot(arg1="bpm3a")
# Check that the curve is added to the plot
assert len(wf.plot_item.curves) == 2
# Check that the curve is accessible by label
assert wf.get_curve("bpm4i-bpm4i") == c1
assert wf.get_curve("bpm3a-bpm3a") == c2
# Check that the curve is accessible by index
assert wf.get_curve(0) == c1
assert wf.get_curve(1) == c2
# Check that the curve is accessible by label
assert wf["bpm4i-bpm4i"] == c1
assert wf["bpm3a-bpm3a"] == c2
assert wf[0] == c1
assert wf[1] == c2
assert wf.curves[0] == c1
assert wf.curves[1] == c2
def test_find_curve_by_label(qtbot, mocked_client):
"""
Test the _find_curve_by_label method returns the correct curve or None if not found.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c1 = wf.plot(arg1="bpm4i", label="c1_label")
c2 = wf.plot(arg1="bpm3a", label="c2_label")
found = wf._find_curve_by_label("c1_label")
assert found == c1, "Should return the first curve"
missing = wf._find_curve_by_label("bogus_label")
assert missing is None, "Should return None if not found"
def test_set_x_mode(qtbot, mocked_client):
"""
Test that setting x_mode updates the internal x-axis mode state and switches
the bottom axis of the plot.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_mode = "timestamp"
assert wf.x_axis_mode["name"] == "timestamp"
# When x_mode is 'timestamp', the bottom axis should be a DateAxisItem.
assert isinstance(wf.plot_item.axes["bottom"]["item"], DateAxisItem)
wf.x_mode = "index"
# For other modes, the bottom axis becomes the default AxisItem.
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
wf.x_mode = "samx"
assert wf.x_axis_mode["name"] == "samx"
assert isinstance(wf.plot_item.axes["bottom"]["item"], pg.AxisItem)
def test_color_palette_update(qtbot, mocked_client):
"""
Test that updating the color_palette property changes the color of existing curves.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
original_color = curve.config.color
# Change to a different valid palette
wf.color_palette = "plasma"
assert wf.config.color_palette == "plasma"
# After updating the palette, the curve's color should be re-generated.
assert curve.config.color != original_color
def test_curve_json_property(qtbot, mocked_client):
"""
Test that the curve_json property returns a JSON string representing
non-custom curves. Since custom curves are not serialized, if only a custom
curve is added, an empty list should be returned.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
json_str = wf.curve_json
data = json.loads(json_str)
assert isinstance(data, list)
# Only custom curves exist so none should be serialized.
assert len(data) == 0
def test_remove_curve_waveform(qtbot, mocked_client):
"""
Test that curves can be removed from the waveform using either their label or index.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
wf.plot(x=[4, 5, 6], y=[7, 8, 9], label="curve2")
num_before = len(wf.plot_item.curves)
wf.remove_curve("curve1")
num_after = len(wf.plot_item.curves)
assert num_after == num_before - 1
wf.remove_curve(0)
assert len(wf.plot_item.curves) == num_after - 1
def test_get_all_data_empty(qtbot, mocked_client):
"""
Test that get_all_data returns an empty dictionary when no curves have been added.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
all_data = wf.get_all_data(output="dict")
assert all_data == {}
def test_get_all_data_dict(qtbot, mocked_client):
"""
Test that get_all_data returns a dictionary with the expected x and y data for each curve.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve1")
wf.plot(x=[7, 8, 9], y=[10, 11, 12], label="curve2")
all_data = wf.get_all_data(output="dict")
expected = {
"curve1": {"x": [1, 2, 3], "y": [4, 5, 6]},
"curve2": {"x": [7, 8, 9], "y": [10, 11, 12]},
}
assert all_data == expected
def test_curve_json_getter_setter(qtbot, mocked_client):
"""
Test that the curve_json getter returns a JSON string representing device curves
and that setting curve_json re-creates the curves.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# These curves should be in JSON
wf.plot(arg1="bpm4i")
wf.plot(arg1="bpm3a")
# Custom curves should be ignored
wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_curve")
wf.plot([1, 2, 3, 4])
# Get JSON from the getter.
json_str = wf.curve_json
curve_configs = json.loads(json_str)
# Only device curves are serialized; expect two configurations.
assert isinstance(curve_configs, list)
assert len(curve_configs) == 2
labels = [cfg["label"] for cfg in curve_configs]
assert "bpm4i-bpm4i" in labels
assert "bpm3a-bpm3a" in labels
# Clear all curves.
wf.clear_all()
assert len(wf.plot_item.curves) == 0
# Use the JSON setter to re-create the curves.
wf.curve_json = json_str
# After setting, the waveform should have two curves.
assert len(wf.plot_item.curves) == 2
new_labels = [curve.name() for curve in wf.plot_item.curves]
for lab in labels:
assert lab in new_labels
def test_curve_json_setter_ignores_custom(qtbot, mocked_client):
"""
Test that when curve_json setter is given a JSON string containing a
curve with source "custom", that curve is not added.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
device_curve_config = {
"widget_class": "Curve",
"parent_id": wf.gui_id,
"label": "device_curve",
"color": "#ff0000",
"source": "device",
"signal": {"name": "bpm4i", "entry": "bpm4i", "dap": None},
}
custom_curve_config = {
"widget_class": "Curve",
"parent_id": wf.gui_id,
"label": "custom_curve",
"color": "#00ff00",
"source": "custom",
# No signal for custom curves.
}
json_str = json.dumps([device_curve_config, custom_curve_config], indent=2)
wf.curve_json = json_str
# Only the device curve should be added.
curves = wf.plot_item.curves
assert len(curves) == 1
assert curves[0].name() == "device_curve"
##################################################
# Waveform widget scan logic tests
##################################################
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])},
"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
def test_update_sync_curves(monkeypatch, qtbot, mocked_client):
"""
Test that update_sync_curves retrieves live data correctly and calls setData on sync curves.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(arg1="bpm4i")
wf._sync_curves = [c]
wf.x_mode = "timestamp"
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
recorded = {}
def fake_setData(x, y):
recorded["x"] = x
recorded["y"] = y
monkeypatch.setattr(c, "setData", fake_setData)
wf.update_sync_curves()
np.testing.assert_array_equal(recorded.get("x"), [101, 201, 301])
np.testing.assert_array_equal(recorded.get("y"), [5, 6, 7])
def test_update_async_curves(monkeypatch, qtbot, mocked_client):
"""
Test that update_async_curves retrieves live data correctly and calls setData on async curves.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(arg1="async_device", label="async_device-async_device")
wf._async_curves = [c]
wf.x_mode = "timestamp"
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
recorded = {}
def fake_setData(x, y):
recorded["x"] = x
recorded["y"] = y
monkeypatch.setattr(c, "setData", fake_setData)
wf.update_async_curves()
np.testing.assert_array_equal(recorded.get("x"), [11, 21, 31])
np.testing.assert_array_equal(recorded.get("y"), [1, 2, 3])
def test_get_x_data_custom(monkeypatch, qtbot, mocked_client):
"""
Test that _get_x_data returns the correct custom signal data.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Set x_mode to a custom mode.
wf.x_axis_mode["name"] = "custom_signal"
wf.x_axis_mode["entry"] = "custom_entry"
dummy_data = DummyData(val=[50, 60, 70], timestamps=[150, 160, 170])
dummy_live = {"custom_signal": {"custom_entry": dummy_data}}
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
x_data = wf._get_x_data("irrelevant", "irrelevant")
np.testing.assert_array_equal(x_data, [50, 60, 70])
def test_get_x_data_timestamp(monkeypatch, qtbot, mocked_client):
"""
Test that _get_x_data returns the correct timestamp data.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.x_axis_mode["name"] = "timestamp"
dummy_data = DummyData(val=[50, 60, 70], timestamps=[101, 202, 303])
dummy_live = {"deviceX": {"entryX": dummy_data}}
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (dummy_live, "val"))
x_data = wf._get_x_data("deviceX", "entryX")
np.testing.assert_array_equal(x_data, [101, 202, 303])
def test_categorise_device_curves(monkeypatch, qtbot, mocked_client):
"""
Test that _categorise_device_curves correctly categorizes curves.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
c_sync = wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
c_async = wf.plot(arg1="async_device", label="async_device-async_device")
mode = wf._categorise_device_curves()
assert mode == "mixed"
assert c_sync in wf._sync_curves
assert c_async in wf._async_curves
@pytest.mark.parametrize(
["mode", "calls"], [("sync", (1, 0)), ("async", (0, 1)), ("mixed", (1, 1))]
)
def test_on_scan_status(qtbot, mocked_client, monkeypatch, mode, calls):
"""
Test that on_scan_status sets up a new scan correctly,
categorizes curves, and triggers sync/async updates as needed.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Force creation of a couple of device curves
if mode == "sync":
wf.plot(arg1="bpm4i")
elif mode == "async":
wf.plot(arg1="async_device")
else:
wf.plot(arg1="bpm4i")
wf.plot(arg1="async_device")
# We mock out the scan_item, pretending we found a new scan.
dummy_scan = create_dummy_scan_item()
dummy_scan.metadata["bec"]["scan_id"] = "1234"
monkeypatch.setattr(wf.queue.scan_storage, "find_scan_by_ID", lambda scan_id: dummy_scan)
# We'll track calls to sync_signal_update and async_signal_update
sync_spy = MagicMock()
async_spy = MagicMock()
wf.sync_signal_update.connect(sync_spy)
wf.async_signal_update.connect(async_spy)
# Prepare fake message data
msg = {"scan_id": "1234"}
meta = {}
wf.on_scan_status(msg, meta)
assert wf.scan_id == "1234"
assert wf.scan_item == dummy_scan
assert wf._mode == mode
assert sync_spy.call_count == calls[0], "sync_signal_update should be called exactly once"
assert async_spy.call_count == calls[1], "async_signal_update should be called exactly once"
def test_add_dap_curve(qtbot, mocked_client_with_dap, monkeypatch):
"""
Test add_dap_curve creates a new DAP curve from an existing device curve
and verifies that the DAP call doesn't fail due to mock-based plugin_info.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", label="bpm4i-bpm4i")
dap_curve = wf.add_dap_curve(device_label="bpm4i-bpm4i", dap_name="GaussianModel")
assert dap_curve is not None
assert dap_curve.config.source == "dap"
assert dap_curve.config.signal.name == "bpm4i"
assert dap_curve.config.signal.dap == "GaussianModel"
def test_fetch_scan_data_and_access(qtbot, mocked_client, monkeypatch):
"""
Test the _fetch_scan_data_and_access method returns live_data/val if in a live scan,
or device dict/value if in a historical scan. Also test fallback if no scan_item.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.scan_item = None
hist_mock = MagicMock()
monkeypatch.setattr(wf, "update_with_scan_history", hist_mock)
wf._fetch_scan_data_and_access()
hist_mock.assert_called_once_with(-1)
# Ckeck live mode
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
data_dict, access_key = wf._fetch_scan_data_and_access()
assert data_dict == dummy_scan.live_data
assert access_key == "val"
# Check history mode
del dummy_scan.live_data
dummy_scan.devices = {"some_device": {"some_entry": "some_value"}}
data_dict, access_key = wf._fetch_scan_data_and_access()
assert "some_device" in data_dict # from dummy_scan.devices
assert access_key == "value"
def test_setup_async_curve(qtbot, mocked_client, monkeypatch):
"""
Test that _setup_async_curve properly disconnects old signals
and re-connects the async readback for a new scan ID.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
wf.old_scan_id = "111"
wf.scan_id = "222"
c = wf.plot(arg1="async_device", label="async_device-async_device")
# check that it was placed in _async_curves or so
wf._async_curves = [c]
# We'll spy on connect_slot
connect_spy = MagicMock()
monkeypatch.setattr(wf.bec_dispatcher, "connect_slot", connect_spy)
wf._setup_async_curve(c)
connect_spy.assert_called_once()
endpoint_called = connect_spy.call_args[0][1].endpoint
# We expect MessageEndpoints.device_async_readback('222', 'async_device')
assert "222" in endpoint_called
assert "async_device" in endpoint_called
@pytest.mark.parametrize("x_mode", ("timestamp", "index"))
def test_on_async_readback(qtbot, mocked_client, x_mode):
"""
Test that on_async_readback extends or replaces async data depending on metadata instruction.
For 'timestamp' mode, new timestamps are appended to x_data.
For 'index' mode, x_data simply increases by integer index.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
c = wf.plot(arg1="async_device", label="async_device-async_device")
wf._async_curves = [c]
# Suppose existing data
c.setData([0, 1, 2], [10, 11, 12])
# Set the x_axis_mode
wf.x_axis_mode["name"] = x_mode
# Extend readback
msg = {"signals": {"async_device": {"value": [100, 200], "timestamp": [1001, 1002]}}}
metadata = {"async_update": {"max_shape": [None], "type": "add"}}
wf.on_async_readback(msg, metadata)
x_data, y_data = c.get_data()
assert len(x_data) == 5
# Check x_data based on x_mode
if x_mode == "timestamp":
np.testing.assert_array_equal(x_data, [0, 1, 2, 1001, 1002])
else: # x_mode == "index"
np.testing.assert_array_equal(x_data, [0, 1, 2, 3, 4])
np.testing.assert_array_equal(y_data, [10, 11, 12, 100, 200])
# instruction='replace'
msg2 = {"signals": {"async_device": {"value": [999], "timestamp": [555]}}}
metadata2 = {"async_update": {"max_shape": [None], "type": "replace"}}
wf.on_async_readback(msg2, metadata2)
x_data2, y_data2 = c.get_data()
if x_mode == "timestamp":
np.testing.assert_array_equal(x_data2, [555])
else:
np.testing.assert_array_equal(x_data2, [0])
np.testing.assert_array_equal(y_data2, [999])
def test_get_x_data(qtbot, mocked_client, monkeypatch):
"""
Test _get_x_data logic for multiple modes: 'timestamp', 'index', 'custom', 'auto'.
Use a dummy scan_item that returns specific data for the requested signal.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
dummy_scan = create_dummy_scan_item()
wf.scan_item = dummy_scan
# 1) x_mode == 'timestamp'
wf.x_axis_mode["name"] = "timestamp"
x_data = wf._get_x_data("bpm4i", "bpm4i")
np.testing.assert_array_equal(x_data, [101, 201, 301])
# 2) x_mode == 'index' => returns None => means use Y data indexing
wf.x_axis_mode["name"] = "index"
x_data2 = wf._get_x_data("bpm4i", "bpm4i")
assert x_data2 is None
# 3) custom x => e.g. "samx"
wf.x_axis_mode["name"] = "samx"
x_custom = wf._get_x_data("bpm4i", "bpm4i")
# because dummy_scan.live_data["samx"]["samx"].val => [10,20,30]
np.testing.assert_array_equal(x_custom, [10, 20, 30])
# 4) auto
wf._async_curves.clear()
wf._sync_curves = [MagicMock()] # pretend we have a sync device
wf.x_axis_mode["name"] = "auto"
x_auto = wf._get_x_data("bpm4i", "bpm4i")
# By default it tries the "scan_report_devices" => "samx" => same as custom above
np.testing.assert_array_equal(x_auto, [10, 20, 30])
##################################################
# The following tests are for the Curve class
##################################################
def test_curve_set_appearance_methods(qtbot, mocked_client):
"""
Test that the Curve appearance setter methods update the configuration properly.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="appearance_curve")
c.set_color("#0000ff")
c.set_symbol("x")
c.set_symbol_color("#ff0000")
c.set_symbol_size(10)
c.set_pen_width(3)
c.set_pen_style("dashdot")
assert c.config.color == "#0000ff"
assert c.config.symbol == "x"
assert c.config.symbol_color == "#ff0000"
assert c.config.symbol_size == 10
assert c.config.pen_width == 3
assert c.config.pen_style == "dashdot"
def test_curve_set_custom_data(qtbot, mocked_client):
"""
Test that custom curves allow setting new data via set_data.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="custom_data_curve")
# Change data
c.set_data([7, 8, 9], [10, 11, 12])
x_data, y_data = c.get_data()
np.testing.assert_array_equal(x_data, np.array([7, 8, 9]))
np.testing.assert_array_equal(y_data, np.array([10, 11, 12]))
def test_curve_set_data_error_non_custom(qtbot, mocked_client):
"""
Test that calling set_data on a non-custom (device) curve raises a ValueError.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
# Create a device curve by providing y_name (which makes source 'device')
# Assume that entry_validator returns a valid entry.
c = wf.plot(arg1="bpm4i", label="device_curve")
with pytest.raises(ValueError):
c.set_data([1, 2, 3], [4, 5, 6])
def test_curve_remove(qtbot, mocked_client):
"""
Test that calling remove() on a Curve calls its parent's remove_curve method.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c1 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_1")
c2 = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="curve_2")
assert len(wf.plot_item.curves) == 2
c1.remove()
assert len(wf.plot_item.curves) == 1
assert c1 not in wf.plot_item.curves
assert c2 in wf.plot_item.curves
def test_curve_dap_params_and_summary(qtbot, mocked_client):
"""
Test that dap_params and dap_summary properties work as expected.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="dap_curve")
c.dap_params = {"param": 1}
c.dap_summary = {"summary": "test"}
assert c.dap_params == {"param": 1}
assert c.dap_summary == {"summary": "test"}
def test_curve_set_method(qtbot, mocked_client):
"""
Test the convenience set(...) method of the Curve for updating appearance properties.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="set_method_curve")
c.set(
color="#123456",
symbol="d",
symbol_color="#654321",
symbol_size=12,
pen_width=5,
pen_style="dot",
)
assert c.config.color == "#123456"
assert c.config.symbol == "d"
assert c.config.symbol_color == "#654321"
assert c.config.symbol_size == 12
assert c.config.pen_width == 5
assert c.config.pen_style == "dot"
##################################################
# Settings and popups
##################################################
def test_show_curve_settings_popup(qtbot, mocked_client):
"""
Test that show_curve_settings_popup displays the settings dialog and toggles the toolbar icon.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client)
curve_action = wf.toolbar.widgets["curve"].action
assert not curve_action.isChecked(), "Should start unchecked"
wf.show_curve_settings_popup()
assert wf.curve_settings_dialog is not None
assert wf.curve_settings_dialog.isVisible()
assert curve_action.isChecked()
wf.curve_settings_dialog.close()
assert wf.curve_settings_dialog is None
assert not curve_action.isChecked(), "Should be unchecked after closing dialog"
def test_show_dap_summary_popup(qtbot, mocked_client):
"""
Test that show_dap_summary_popup displays the DAP summary dialog and toggles the 'fit_params' toolbar icon.
"""
wf = create_widget(qtbot, Waveform, client=mocked_client, popups=True)
assert "fit_params" in wf.toolbar.widgets
fit_action = wf.toolbar.widgets["fit_params"].action
assert fit_action.isChecked() is False
wf.show_dap_summary_popup()
assert wf.dap_summary_dialog is not None
assert wf.dap_summary_dialog.isVisible()
assert fit_action.isChecked() is True
wf.dap_summary_dialog.close()
assert wf.dap_summary_dialog is None
assert fit_action.isChecked() is False

View File

@@ -1,573 +0,0 @@
from unittest.mock import MagicMock, patch
import pyqtgraph as pg
import pytest
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QApplication
from bec_widgets.qt_utils.settings_dialog import SettingsDialog
from bec_widgets.utils.colors import apply_theme, get_theme_palette, set_theme
from bec_widgets.utils.linear_region_selector import LinearRegionWrapper
from bec_widgets.widgets.containers.figure.plots.axis_settings import AxisSettings
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,
)
from bec_widgets.widgets.plots.waveform.waveform_widget import BECWaveformWidget
from .client_mocks import mocked_client
from .conftest import create_widget
@pytest.fixture
def waveform_widget(qtbot, mocked_client):
models = ["GaussianModel", "LorentzModel", "SineModel"]
mocked_client.dap._available_dap_plugins.keys.return_value = models
widget = BECWaveformWidget(client=mocked_client())
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def mock_waveform(waveform_widget):
waveform_mock = MagicMock()
waveform_widget.waveform = waveform_mock
return waveform_mock
def test_waveform_widget_init(waveform_widget):
assert waveform_widget is not None
assert waveform_widget.client is not None
assert isinstance(waveform_widget, BECWaveformWidget)
assert waveform_widget.config.widget_class == "BECWaveformWidget"
###################################
# Wrapper methods for Waveform
###################################
def test_waveform_widget_get_curve(waveform_widget, mock_waveform):
waveform_widget.get_curve("curve_id")
waveform_widget.waveform.get_curve.assert_called_once_with("curve_id")
def test_waveform_widget_set_colormap(waveform_widget, mock_waveform):
waveform_widget.set_colormap("colormap")
waveform_widget.waveform.set_colormap.assert_called_once_with("colormap")
def test_waveform_widget_set_x(waveform_widget, mock_waveform):
waveform_widget.set_x("samx", "samx")
waveform_widget.waveform.set_x.assert_called_once_with("samx", "samx")
def test_waveform_plot_data(waveform_widget, mock_waveform):
waveform_widget.plot(x=[1, 2, 3], y=[1, 2, 3])
waveform_widget.waveform.plot.assert_called_once_with(
arg1=None,
x=[1, 2, 3],
y=[1, 2, 3],
x_name=None,
y_name=None,
z_name=None,
x_entry=None,
y_entry=None,
z_entry=None,
color=None,
color_map_z="magma",
label=None,
validate=True,
dap=None,
)
def test_waveform_plot_scan_curves(waveform_widget, mock_waveform):
waveform_widget.plot(x_name="samx", y_name="samy", dap="GaussianModel")
waveform_widget.waveform.plot.assert_called_once_with(
arg1=None,
x=None,
y=None,
x_name="samx",
y_name="samy",
z_name=None,
x_entry=None,
y_entry=None,
z_entry=None,
color=None,
color_map_z="magma",
label=None,
validate=True,
dap="GaussianModel",
)
def test_waveform_widget_add_dap(waveform_widget, mock_waveform):
waveform_widget.add_dap(x_name="samx", y_name="bpm4i", dap="GaussianModel")
waveform_widget.waveform.add_dap.assert_called_once_with(
x_name="samx",
y_name="bpm4i",
x_entry=None,
y_entry=None,
color=None,
dap="GaussianModel",
validate_bec=True,
)
def test_waveform_widget_get_dap_params(waveform_widget, mock_waveform):
waveform_widget.get_dap_params()
waveform_widget.waveform.get_dap_params.assert_called_once()
def test_waveform_widget_get_dap_summary(waveform_widget, mock_waveform):
waveform_widget.get_dap_summary()
waveform_widget.waveform.get_dap_summary.assert_called_once()
def test_waveform_widget_remove_curve(waveform_widget, mock_waveform):
waveform_widget.remove_curve("curve_id")
waveform_widget.waveform.remove_curve.assert_called_once_with("curve_id")
def test_waveform_widget_scan_history(waveform_widget, mock_waveform):
waveform_widget.scan_history(0)
waveform_widget.waveform.scan_history.assert_called_once_with(0, None)
def test_waveform_widget_get_all_data(waveform_widget, mock_waveform):
waveform_widget.get_all_data()
waveform_widget.waveform.get_all_data.assert_called_once()
def test_waveform_widget_set_title(waveform_widget, mock_waveform):
waveform_widget.set_title("Title")
waveform_widget.waveform.set_title.assert_called_once_with("Title")
def test_waveform_widget_set_base(waveform_widget, mock_waveform):
waveform_widget.set(
title="Test Title",
x_label="X Label",
y_label="Y Label",
x_scale="linear",
y_scale="log",
x_lim=(0, 10),
y_lim=(0, 10),
legend_label_size=12,
)
waveform_widget.waveform.set.assert_called_once_with(
title="Test Title",
x_label="X Label",
y_label="Y Label",
x_scale="linear",
y_scale="log",
x_lim=(0, 10),
y_lim=(0, 10),
legend_label_size=12,
)
def test_waveform_widget_set_x_label(waveform_widget, mock_waveform):
waveform_widget.set_x_label("X Label")
waveform_widget.waveform.set_x_label.assert_called_once_with("X Label")
def test_waveform_widget_set_y_label(waveform_widget, mock_waveform):
waveform_widget.set_y_label("Y Label")
waveform_widget.waveform.set_y_label.assert_called_once_with("Y Label")
def test_waveform_widget_set_x_scale(waveform_widget, mock_waveform):
waveform_widget.set_x_scale("linear")
waveform_widget.waveform.set_x_scale.assert_called_once_with("linear")
def test_waveform_widget_set_y_scale(waveform_widget, mock_waveform):
waveform_widget.set_y_scale("log")
waveform_widget.waveform.set_y_scale.assert_called_once_with("log")
def test_waveform_widget_set_x_lim(waveform_widget, mock_waveform):
waveform_widget.set_x_lim((0, 10))
waveform_widget.waveform.set_x_lim.assert_called_once_with((0, 10))
def test_waveform_widget_set_y_lim(waveform_widget, mock_waveform):
waveform_widget.set_y_lim((0, 10))
waveform_widget.waveform.set_y_lim.assert_called_once_with((0, 10))
def test_waveform_widget_set_legend_label_size(waveform_widget, mock_waveform):
waveform_widget.set_legend_label_size(12)
waveform_widget.waveform.set_legend_label_size.assert_called_once_with(12)
def test_waveform_widget_set_auto_range(waveform_widget, mock_waveform):
waveform_widget.set_auto_range(True, "xy")
waveform_widget.waveform.set_auto_range.assert_called_once_with(True, "xy")
def test_waveform_widget_set_grid(waveform_widget, mock_waveform):
waveform_widget.set_grid(True, False)
waveform_widget.waveform.set_grid.assert_called_once_with(True, False)
def test_waveform_widget_lock_aspect_ratio(waveform_widget, mock_waveform):
waveform_widget.lock_aspect_ratio(True)
waveform_widget.waveform.lock_aspect_ratio.assert_called_once_with(True)
def test_waveform_widget_export(waveform_widget, mock_waveform):
waveform_widget.export()
waveform_widget.waveform.export.assert_called_once()
###################################
# ToolBar interactions
###################################
def test_toolbar_drag_mode_action_triggered(waveform_widget, qtbot):
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
action_drag.trigger()
assert action_drag.isChecked() == True
assert action_rectangle.isChecked() == False
def test_toolbar_rectangle_mode_action_triggered(waveform_widget, qtbot):
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
action_rectangle.trigger()
assert action_drag.isChecked() == False
assert action_rectangle.isChecked() == True
def test_toolbar_auto_range_action_triggered(waveform_widget, mock_waveform, qtbot):
action = waveform_widget.toolbar.widgets["auto_range"].action
action.trigger()
qtbot.wait(200)
waveform_widget.waveform.set_auto_range.assert_called_once_with(True, "xy")
def test_enable_mouse_pan_mode(qtbot, waveform_widget):
action_drag = waveform_widget.toolbar.widgets["drag_mode"].action
action_rectangle = waveform_widget.toolbar.widgets["rectangle_mode"].action
mock_view_box = MagicMock()
waveform_widget.waveform.plot_item.getViewBox = MagicMock(return_value=mock_view_box)
waveform_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)
###################################
# Curve Dialog Tests
###################################
def show_curve_dialog(qtbot, waveform_widget):
curve_dialog = SettingsDialog(
waveform_widget,
settings_widget=CurveSettings(),
window_title="Curve Settings",
config=waveform_widget.waveform._curves_data,
)
qtbot.addWidget(curve_dialog)
qtbot.waitExposed(curve_dialog)
return curve_dialog
def test_curve_dialog_scan_curves_interactions(qtbot, waveform_widget):
waveform_widget.plot(y_name="bpm4i")
waveform_widget.plot(y_name="bpm3a")
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
# Check default display of config from waveform widget
assert curve_dialog is not None
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "bpm3a"
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 1).text() == "bpm3a"
assert curve_dialog.widget.ui.x_mode.currentText() == "best_effort"
assert curve_dialog.widget.ui.x_name.isEnabled() == False
assert curve_dialog.widget.ui.x_entry.isEnabled() == False
# Add a new curve
curve_dialog.widget.ui.add_curve.click()
qtbot.wait(200)
assert curve_dialog.widget.ui.scan_table.rowCount() == 3
# Set device to new curve
curve_dialog.widget.ui.scan_table.cellWidget(2, 0).setText("bpm3i")
# Change the x mode to device
curve_dialog.widget.ui.x_mode.setCurrentText("device")
qtbot.wait(200)
assert curve_dialog.widget.ui.x_name.isEnabled() == True
assert curve_dialog.widget.ui.x_entry.isEnabled() == True
# Set the x device
curve_dialog.widget.ui.x_name.setText("samx")
# Delete first curve ('bpm4i')
curve_dialog.widget.ui.scan_table.cellWidget(0, 6).click()
qtbot.wait(200)
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm3a"
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm3a"
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "bpm3i"
# Close the dialog
curve_dialog.accept()
qtbot.wait(200)
# Check the curve data in the target widget
assert list(waveform_widget.waveform._curves_data["scan_segment"].keys()) == [
"bpm3a-bpm3a",
"bpm3i-bpm3i",
]
assert len(waveform_widget.curves) == 2
def test_curve_dialog_async(qtbot, waveform_widget):
waveform_widget.plot(y_name="bpm4i")
waveform_widget.plot(y_name="async_device")
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
assert curve_dialog is not None
assert curve_dialog.widget.ui.scan_table.rowCount() == 2
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 0).text() == "async_device"
assert curve_dialog.widget.ui.scan_table.cellWidget(1, 1).text() == "async_device"
def test_curve_dialog_dap(qtbot, waveform_widget):
# Don't use default dap for curve_dialog dialog
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="LorentzModel")
curve_dialog = show_curve_dialog(qtbot, waveform_widget)
assert curve_dialog is not None
assert curve_dialog.widget.ui.scan_table.rowCount() == 1
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 0).text() == "bpm4i"
assert curve_dialog.widget.ui.scan_table.cellWidget(0, 1).text() == "bpm4i"
assert curve_dialog.widget.ui.dap_table.isEnabled() == True
assert curve_dialog.widget.ui.dap_table.rowCount() == 1
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 0).text() == "bpm4i"
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 1).text() == "bpm4i"
assert curve_dialog.widget.ui.dap_table.cellWidget(0, 2).currentText() == "LorentzModel"
assert curve_dialog.widget.ui.x_mode.currentText() == "device"
assert curve_dialog.widget.ui.x_name.isEnabled() == True
assert curve_dialog.widget.ui.x_entry.isEnabled() == True
assert curve_dialog.widget.ui.x_name.text() == "samx"
assert curve_dialog.widget.ui.x_entry.text() == "samx"
curve_dialog.accept()
qtbot.wait(200)
assert list(waveform_widget.waveform._curves_data["scan_segment"].keys()) == ["bpm4i-bpm4i"]
assert len(waveform_widget.curves) == 2
def test_fit_dialog_summary(qtbot, waveform_widget):
"""Test the fit dialog summary widget"""
waveform_widget.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
fit_dialog_summary = create_widget(qtbot, FitSummaryWidget, target_widget=waveform_widget)
assert fit_dialog_summary.dap_dialog.fit_curve_id == "bpm4i-bpm4i-GaussianModel"
assert fit_dialog_summary.dap_dialog.ui.curve_list.count() == 1
###################################
# Axis Dialog Tests
###################################
def show_axis_dialog(qtbot, waveform_widget):
axis_dialog = SettingsDialog(
waveform_widget,
settings_widget=AxisSettings(),
window_title="Axis Settings",
config=waveform_widget._config_dict["axis"],
)
qtbot.addWidget(axis_dialog)
qtbot.waitExposed(axis_dialog)
return axis_dialog
def test_axis_dialog_with_axis_limits(qtbot, waveform_widget):
waveform_widget.set(
title="Test Title",
x_label="X Label",
y_label="Y Label",
x_scale="linear",
y_scale="log",
x_lim=(0, 10),
y_lim=(0, 10),
)
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
assert axis_dialog is not None
assert axis_dialog.widget.ui.plot_title.text() == "Test Title"
assert axis_dialog.widget.ui.x_label.text() == "X Label"
assert axis_dialog.widget.ui.y_label.text() == "Y Label"
assert axis_dialog.widget.ui.x_scale.currentText() == "linear"
assert axis_dialog.widget.ui.y_scale.currentText() == "log"
assert axis_dialog.widget.ui.x_min.value() == 0
assert axis_dialog.widget.ui.x_max.value() == 10
assert axis_dialog.widget.ui.y_min.value() == 0
assert axis_dialog.widget.ui.y_max.value() == 10
def test_axis_dialog_without_axis_limits(qtbot, waveform_widget):
waveform_widget.set(
title="Test Title", x_label="X Label", y_label="Y Label", x_scale="linear", y_scale="log"
)
x_range = waveform_widget.fig.widget_list[0].plot_item.viewRange()[0]
y_range = waveform_widget.fig.widget_list[0].plot_item.viewRange()[1]
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
assert axis_dialog is not None
assert axis_dialog.widget.ui.plot_title.text() == "Test Title"
assert axis_dialog.widget.ui.x_label.text() == "X Label"
assert axis_dialog.widget.ui.y_label.text() == "Y Label"
assert axis_dialog.widget.ui.x_scale.currentText() == "linear"
assert axis_dialog.widget.ui.y_scale.currentText() == "log"
assert axis_dialog.widget.ui.x_min.value() == x_range[0]
assert axis_dialog.widget.ui.x_max.value() == x_range[1]
assert axis_dialog.widget.ui.y_min.value() == y_range[0]
assert axis_dialog.widget.ui.y_max.value() == y_range[1]
def test_axis_dialog_set_properties(qtbot, waveform_widget):
axis_dialog = show_axis_dialog(qtbot, waveform_widget)
axis_dialog.widget.ui.plot_title.setText("New Title")
axis_dialog.widget.ui.x_label.setText("New X Label")
axis_dialog.widget.ui.y_label.setText("New Y Label")
axis_dialog.widget.ui.x_scale.setCurrentText("log")
axis_dialog.widget.ui.y_scale.setCurrentText("linear")
axis_dialog.widget.ui.x_min.setValue(5)
axis_dialog.widget.ui.x_max.setValue(15)
axis_dialog.widget.ui.y_min.setValue(5)
axis_dialog.widget.ui.y_max.setValue(15)
axis_dialog.accept()
assert waveform_widget._config_dict["axis"]["title"] == "New Title"
assert waveform_widget._config_dict["axis"]["x_label"] == "New X Label"
assert waveform_widget._config_dict["axis"]["y_label"] == "New Y Label"
assert waveform_widget._config_dict["axis"]["x_scale"] == "log"
assert waveform_widget._config_dict["axis"]["y_scale"] == "linear"
assert waveform_widget._config_dict["axis"]["x_lim"] == (5, 15)
assert waveform_widget._config_dict["axis"]["y_lim"] == (5, 15)
def test_waveform_widget_theme_update(qtbot, waveform_widget):
"""Test theme update for waveform widget."""
qapp = QApplication.instance()
# Set the theme directly; equivalent to clicking the dark mode button
# The background color should be black and the axis color should be white
set_theme("dark")
palette = get_theme_palette()
waveform_color_dark = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor(20, 20, 20)
assert waveform_color_dark == palette.text().color()
# Set the theme to light; equivalent to clicking the light mode button
# The background color should be white and the axis color should be black
set_theme("light")
palette = get_theme_palette()
waveform_color_light = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor(233, 236, 239)
assert waveform_color_light == palette.text().color()
assert waveform_color_dark != waveform_color_light
# Set the theme to auto; equivalent starting the application with no theme set
set_theme("auto")
# Simulate that the OS theme changes to dark
qapp.theme_signal.theme_updated.emit("dark")
apply_theme("dark")
# The background color should be black and the axis color should be white
# As we don't have access to the listener here, we can't test the palette change. Instead,
# we compare the waveform color to the dark theme color
waveform_color = waveform_widget.waveform.plot_item.getAxis("left").pen().color()
bg_color = waveform_widget.fig.backgroundBrush().color()
assert bg_color == QColor(20, 20, 20)
assert waveform_color == waveform_color_dark
def test_waveform_roi_selection_creation(waveform_widget, qtbot):
"""Test ROI selection for waveform widget.
This checks that the ROI select is properly created and removed when the button is toggled.
"""
# Check if curve is create upon ROI select slot
# This also checks that the button in the toolbar works
container = []
def callback(msg):
container.append(msg)
waveform_widget.waveform.roi_active.connect(callback)
assert waveform_widget.waveform.roi_select is None
assert waveform_widget.waveform.roi_region == (None, None)
# Toggle the ROI select
waveform_widget.toogle_roi_select(True)
assert isinstance(waveform_widget.waveform.roi_select, LinearRegionWrapper)
# This is the default region for the pg.LinearRegionItem
assert waveform_widget.waveform.roi_region == (0, 1)
# Untoggle the ROI select
waveform_widget.toogle_roi_select(False)
assert waveform_widget.waveform.roi_select is None
assert container[0] is True
assert container[1] is False
def test_waveform_roi_selection_updates_fit(waveform_widget, qtbot):
"""This test checks that upon selection of a new region, the fit is updated and all signals are emitted as expected."""
container = []
def callback(msg):
container.append(msg)
waveform_widget.waveform.roi_changed.connect(callback)
# Mock refresh_dap method
with patch.object(waveform_widget.waveform, "refresh_dap") as mock_refresh_dap:
waveform_widget.toogle_roi_select(True)
waveform_widget.waveform.roi_select.linear_region_selector.setRegion([0.5, 1.5])
qtbot.wait(200)
assert waveform_widget.waveform.roi_region == (0.5, 1.5)
waveform_widget.toogle_roi_select(False)
assert waveform_widget.waveform.roi_region == (None, None)
assert len(container) == 1
assert container[0] == (0.5, 1.5)
# 3 refresh DAP calls: 1x upon hook, 1x unhook and 1x from roi_changed
assert mock_refresh_dap.call_count == 3
def test_waveform_roi_selection_change_color(waveform_widget, qtbot):
"""This test checks that the color of the ROI region can be changed."""
waveform_widget.toogle_roi_select(True)
waveform_widget.waveform.roi_select.change_roi_color((QColor("red"), QColor("blue")))
# I can only get the brush from the RegionSelectItem
assert (
waveform_widget.waveform.roi_select.linear_region_selector.currentBrush.color()
== QColor("red")
)