diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py
index 8b8d3594..42a1b1e8 100644
--- a/bec_widgets/cli/client.py
+++ b/bec_widgets/cli/client.py
@@ -22,6 +22,7 @@ class Widgets(str, enum.Enum):
BECFigure = "BECFigure"
BECImageWidget = "BECImageWidget"
BECMotorMapWidget = "BECMotorMapWidget"
+ BECMultiWaveformWidget = "BECMultiWaveformWidget"
BECProgressBar = "BECProgressBar"
BECQueue = "BECQueue"
BECStatusBox = "BECStatusBox"
@@ -1574,6 +1575,436 @@ class BECMotorMapWidget(RPCBase):
"""
+class BECMultiWaveform(RPCBase):
+ @property
+ @rpc_call
+ def _rpc_id(self) -> "str":
+ """
+ Get the RPC ID of the widget.
+ """
+
+ @property
+ @rpc_call
+ def _config_dict(self) -> "dict":
+ """
+ Get the configuration of the widget.
+
+ Returns:
+ dict: The configuration of the widget.
+ """
+
+ @property
+ @rpc_call
+ def curves(self) -> collections.deque:
+ """
+ Get the curves of the plot widget as a deque.
+ Returns:
+ deque: Deque of curves.
+ """
+
+ @rpc_call
+ def set_monitor(self, monitor: str):
+ """
+ Set the monitor for the plot widget.
+ Args:
+ monitor (str): The monitor to set.
+ """
+
+ @rpc_call
+ def set_opacity(self, opacity: int):
+ """
+ Set the opacity of the curve on the plot.
+
+ Args:
+ opacity(int): The opacity of the curve. 0-100.
+ """
+
+ @rpc_call
+ def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
+ """
+ Set the maximum number of traces to display on the plot.
+
+ Args:
+ max_trace (int): The maximum number of traces to display.
+ flush_buffer (bool): Flush the buffer.
+ """
+
+ @rpc_call
+ def set_curve_highlight(self, index: int):
+ """
+ Set the curve highlight based on visible curves.
+
+ Args:
+ index (int): The index of the curve to highlight among visible curves.
+ """
+
+ @rpc_call
+ def set_colormap(self, colormap: str):
+ """
+ Set the colormap for the scatter plot z gradient.
+
+ Args:
+ colormap(str): Colormap for the scatter plot.
+ """
+
+ @rpc_call
+ def set(self, **kwargs) -> "None":
+ """
+ Set the properties of the plot widget.
+
+ Args:
+ **kwargs: Keyword arguments for the properties to be set.
+
+ Possible properties:
+ - title: str
+ - x_label: str
+ - y_label: str
+ - x_scale: Literal["linear", "log"]
+ - y_scale: Literal["linear", "log"]
+ - x_lim: tuple
+ - y_lim: tuple
+ - legend_label_size: int
+ """
+
+ @rpc_call
+ def set_title(self, title: "str", size: "int" = None):
+ """
+ Set the title of the plot widget.
+
+ Args:
+ title(str): Title of the plot widget.
+ size(int): Font size of the title.
+ """
+
+ @rpc_call
+ def set_x_label(self, label: "str", size: "int" = None):
+ """
+ Set the label of the x-axis.
+
+ Args:
+ label(str): Label of the x-axis.
+ size(int): Font size of the label.
+ """
+
+ @rpc_call
+ def set_y_label(self, label: "str", size: "int" = None):
+ """
+ Set the label of the y-axis.
+
+ Args:
+ label(str): Label of the y-axis.
+ size(int): Font size of the label.
+ """
+
+ @rpc_call
+ def set_x_scale(self, scale: "Literal['linear', 'log']" = "linear"):
+ """
+ Set the scale of the x-axis.
+
+ Args:
+ scale(Literal["linear", "log"]): Scale of the x-axis.
+ """
+
+ @rpc_call
+ def set_y_scale(self, scale: "Literal['linear', 'log']" = "linear"):
+ """
+ Set the scale of the y-axis.
+
+ Args:
+ scale(Literal["linear", "log"]): Scale of the y-axis.
+ """
+
+ @rpc_call
+ def set_x_lim(self, *args) -> "None":
+ """
+ Set the limits of the x-axis. This method can accept either two separate arguments
+ for the minimum and maximum x-axis values, or a single tuple containing both limits.
+
+ Usage:
+ set_x_lim(x_min, x_max)
+ set_x_lim((x_min, x_max))
+
+ Args:
+ *args: A variable number of arguments. Can be two integers (x_min and x_max)
+ or a single tuple with two integers.
+ """
+
+ @rpc_call
+ def set_y_lim(self, *args) -> "None":
+ """
+ Set the limits of the y-axis. This method can accept either two separate arguments
+ for the minimum and maximum y-axis values, or a single tuple containing both limits.
+
+ Usage:
+ set_y_lim(y_min, y_max)
+ set_y_lim((y_min, y_max))
+
+ Args:
+ *args: A variable number of arguments. Can be two integers (y_min and y_max)
+ or a single tuple with two integers.
+ """
+
+ @rpc_call
+ def set_grid(self, x: "bool" = False, y: "bool" = False):
+ """
+ Set the grid of the plot widget.
+
+ Args:
+ x(bool): Show grid on the x-axis.
+ y(bool): Show grid on the y-axis.
+ """
+
+ @rpc_call
+ def set_colormap(self, colormap: str):
+ """
+ Set the colormap for the scatter plot z gradient.
+
+ Args:
+ colormap(str): Colormap for the scatter plot.
+ """
+
+ @rpc_call
+ def enable_fps_monitor(self, enable: "bool" = True):
+ """
+ Enable the FPS monitor.
+
+ Args:
+ enable(bool): True to enable, False to disable.
+ """
+
+ @rpc_call
+ def lock_aspect_ratio(self, lock):
+ """
+ Lock aspect ratio.
+
+ Args:
+ lock(bool): True to lock, False to unlock.
+ """
+
+ @rpc_call
+ def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
+ """
+ Extract all curve data into a dictionary or a pandas DataFrame.
+
+ Args:
+ output (Literal["dict", "pandas"]): Format of the output data.
+
+ Returns:
+ dict | pd.DataFrame: Data of all curves in the specified format.
+ """
+
+ @rpc_call
+ def export(self):
+ """
+ Show the Export Dialog of the plot widget.
+ """
+
+ @rpc_call
+ def remove(self):
+ """
+ Remove the plot widget from the figure.
+ """
+
+
+class BECMultiWaveformWidget(RPCBase):
+ @property
+ @rpc_call
+ def curves(self) -> list[pyqtgraph.graphicsItems.PlotDataItem.PlotDataItem]:
+ """
+ Get the curves of the plot widget as a list
+ Returns:
+ list: List of curves.
+ """
+
+ @rpc_call
+ def set_monitor(self, monitor: str) -> None:
+ """
+ Set the monitor of the plot widget.
+
+ Args:
+ monitor(str): The monitor to set.
+ """
+
+ @rpc_call
+ def set_curve_highlight(self, index: int) -> None:
+ """
+ Set the curve highlight of the plot widget by index
+
+ Args:
+ index(int): The index of the curve to highlight.
+ """
+
+ @rpc_call
+ def set_opacity(self, opacity: int) -> None:
+ """
+ Set the opacity of the plot widget.
+
+ Args:
+ opacity(int): The opacity to set.
+ """
+
+ @rpc_call
+ def set_curve_limit(self, curve_limit: int) -> None:
+ """
+ Set the maximum number of traces to display on the plot widget.
+
+ Args:
+ curve_limit(int): The maximum number of traces to display.
+ """
+
+ @rpc_call
+ def set_buffer_flush(self, flush_buffer: bool) -> None:
+ """
+ Set the buffer flush property of the plot widget.
+
+ Args:
+ flush_buffer(bool): True to flush the buffer, False to not flush the buffer.
+ """
+
+ @rpc_call
+ def set_highlight_last_curve(self, enable: bool) -> None:
+ """
+ Enable or disable highlighting of the last curve.
+
+ Args:
+ enable(bool): True to enable highlighting of the last curve, False to disable.
+ """
+
+ @rpc_call
+ def set_colormap(self, colormap: str) -> None:
+ """
+ Set the colormap of the plot widget.
+
+ Args:
+ colormap(str): The colormap to set.
+ """
+
+ @rpc_call
+ 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
+ """
+
+ @rpc_call
+ def set_title(self, title: str):
+ """
+ Set the title of the plot widget.
+
+ Args:
+ title(str): The title to set.
+ """
+
+ @rpc_call
+ def set_x_label(self, x_label: str):
+ """
+ Set the x-axis label of the plot widget.
+
+ Args:
+ x_label(str): The x-axis label to set.
+ """
+
+ @rpc_call
+ def set_y_label(self, y_label: str):
+ """
+ Set the y-axis label of the plot widget.
+
+ Args:
+ y_label(str): The y-axis label to set.
+ """
+
+ @rpc_call
+ def set_x_scale(self, x_scale: Literal["linear", "log"]):
+ """
+ Set the x-axis scale of the plot widget.
+
+ Args:
+ x_scale(str): The x-axis scale to set.
+ """
+
+ @rpc_call
+ def set_y_scale(self, y_scale: Literal["linear", "log"]):
+ """
+ Set the y-axis scale of the plot widget.
+
+ Args:
+ y_scale(str): The y-axis scale to set.
+ """
+
+ @rpc_call
+ def set_x_lim(self, x_lim: tuple):
+ """
+ Set x-axis limits of the plot widget.
+
+ Args:
+ x_lim(tuple): The x-axis limits to set.
+ """
+
+ @rpc_call
+ def set_y_lim(self, y_lim: tuple):
+ """
+ Set y-axis limits of the plot widget.
+
+ Args:
+ y_lim(tuple): The y-axis limits to set.
+ """
+
+ @rpc_call
+ def set_grid(self, x_grid: bool, y_grid: bool):
+ """
+ Set the grid of the plot widget.
+
+ Args:
+ x_grid(bool): True to enable the x-grid, False to disable.
+ y_grid(bool): True to enable the y-grid, False to disable.
+ """
+
+ @rpc_call
+ def set_colormap(self, colormap: str) -> None:
+ """
+ Set the colormap of the plot widget.
+
+ Args:
+ colormap(str): The colormap to set.
+ """
+
+ @rpc_call
+ def enable_fps_monitor(self, enabled: bool):
+ """
+ Enable or disable the FPS monitor
+
+ Args:
+ enabled(bool): True to enable the FPS monitor, False to disable.
+ """
+
+ @rpc_call
+ def lock_aspect_ratio(self, lock: bool):
+ """
+ Lock the aspect ratio of the plot widget.
+
+ Args:
+ lock(bool): True to lock the aspect ratio, False to unlock.
+ """
+
+ @rpc_call
+ def export(self):
+ """
+ Export the plot widget.
+ """
+
+
class BECPlotBase(RPCBase):
@property
@rpc_call
diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
index 5aa055da..6b47bb58 100644
--- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py
+++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
@@ -56,6 +56,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
# "cm": self.colormap,
"im": self.im,
"mm": self.mm,
+ "mw": self.mw,
}
)
@@ -167,9 +168,11 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
self.im.image("waveform", "1d")
self.d2 = self.dock.add_dock(name="dock_2", position="bottom")
- self.wf = self.d2.add_widget("BECWaveformWidget", row=0, col=0)
- self.wf.plot(x_name="samx", y_name="bpm3a")
- self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
+ self.wf = self.d2.add_widget("BECFigure", row=0, col=0)
+
+ self.mw = self.wf.multi_waveform(monitor="waveform") # , config=config)
+ # self.wf.plot(x_name="samx", y_name="bpm3a")
+ # self.wf.plot(x_name="samx", y_name="bpm4i", dap="GaussianModel")
# self.bar = self.d2.add_widget("RingProgressBar", row=0, col=1)
# self.bar.set_diameter(200)
@@ -210,6 +213,7 @@ if __name__ == "__main__": # pragma: no cover
win = JupyterConsoleWindow()
win.show()
+ win.resize(1200, 800)
app.aboutToQuit.connect(win.close)
sys.exit(app.exec_())
diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py
index 914bdcd5..932a4d11 100644
--- a/bec_widgets/utils/crosshair.py
+++ b/bec_widgets/utils/crosshair.py
@@ -6,13 +6,16 @@ from qtpy.QtCore import QObject, Qt, Signal, Slot
from qtpy.QtWidgets import QApplication
-class NonDownsamplingScatterPlotItem(pg.ScatterPlotItem):
+class CrosshairScatterItem(pg.ScatterPlotItem):
def setDownsampling(self, ds=None, auto=None, method=None):
pass
def setClipToView(self, state):
pass
+ def setAlpha(self, *args, **kwargs):
+ pass
+
class Crosshair(QObject):
# QT Position of mouse cursor
@@ -123,7 +126,7 @@ class Crosshair(QObject):
continue
pen = item.opts["pen"]
color = pen.color() if hasattr(pen, "color") else pg.mkColor(pen)
- marker_moved = NonDownsamplingScatterPlotItem(
+ marker_moved = CrosshairScatterItem(
size=10, pen=pg.mkPen(color), brush=pg.mkBrush(None)
)
marker_moved.skip_auto_range = True
@@ -132,7 +135,7 @@ class Crosshair(QObject):
# Create glowing effect markers for clicked events
for size, alpha in [(18, 64), (14, 128), (10, 255)]:
- marker_clicked = NonDownsamplingScatterPlotItem(
+ marker_clicked = CrosshairScatterItem(
size=size,
pen=pg.mkPen(None),
brush=pg.mkBrush(color.red(), color.green(), color.blue(), alpha),
diff --git a/bec_widgets/widgets/dock/dock_area.py b/bec_widgets/widgets/dock/dock_area.py
index 3d947ff4..0c62d81c 100644
--- a/bec_widgets/widgets/dock/dock_area.py
+++ b/bec_widgets/widgets/dock/dock_area.py
@@ -24,6 +24,7 @@ from bec_widgets.widgets.dark_mode_button.dark_mode_button import DarkModeButton
from bec_widgets.widgets.dock.dock import BECDock, DockConfig
from bec_widgets.widgets.image.image_widget import BECImageWidget
from bec_widgets.widgets.motor_map.motor_map_widget import BECMotorMapWidget
+from bec_widgets.widgets.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
from bec_widgets.widgets.positioner_box.positioner_box import PositionerBox
from bec_widgets.widgets.ring_progress_bar.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.scan_control.scan_control import ScanControl
@@ -85,6 +86,11 @@ class BECDockArea(BECWidget, QWidget):
tooltip="Add Waveform",
filled=True,
),
+ "multi_waveform": MaterialIconAction(
+ icon_name=BECMultiWaveformWidget.ICON_NAME,
+ tooltip="Add Multi Waveform",
+ filled=True,
+ ),
"image": MaterialIconAction(
icon_name=BECImageWidget.ICON_NAME, tooltip="Add Image", filled=True
),
@@ -154,6 +160,9 @@ class BECDockArea(BECWidget, QWidget):
self.toolbar.widgets["menu_plots"].widgets["waveform"].triggered.connect(
lambda: self.add_dock(widget="BECWaveformWidget", prefix="waveform")
)
+ self.toolbar.widgets["menu_plots"].widgets["multi_waveform"].triggered.connect(
+ lambda: self.add_dock(widget="BECMultiWaveformWidget", prefix="multi_waveform")
+ )
self.toolbar.widgets["menu_plots"].widgets["image"].triggered.connect(
lambda: self.add_dock(widget="BECImageWidget", prefix="image")
)
diff --git a/bec_widgets/widgets/figure/figure.py b/bec_widgets/widgets/figure/figure.py
index 2cba90c2..88ec8a55 100644
--- a/bec_widgets/widgets/figure/figure.py
+++ b/bec_widgets/widgets/figure/figure.py
@@ -11,6 +11,7 @@ from bec_lib.logger import bec_logger
from pydantic import Field, ValidationError, field_validator
from qtpy.QtCore import Signal as pyqtSignal
from qtpy.QtWidgets import QWidget
+from tornado.gen import multi
from typeguard import typechecked
from bec_widgets.utils import ConnectionConfig, WidgetContainerUtils
@@ -18,6 +19,10 @@ from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.widgets.figure.plots.image.image import BECImageShow, ImageConfig
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap, MotorMapConfig
+from bec_widgets.widgets.figure.plots.multi_waveform.multi_waveform import (
+ BECMultiWaveform,
+ BECMultiWaveformConfig,
+)
from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform, Waveform1DConfig
@@ -64,6 +69,7 @@ class WidgetHandler:
"BECWaveform": (BECWaveform, Waveform1DConfig),
"BECImageShow": (BECImageShow, ImageConfig),
"BECMotorMap": (BECMotorMap, MotorMapConfig),
+ "BECMultiWaveform": (BECMultiWaveform, BECMultiWaveformConfig),
}
def create_widget(
@@ -134,8 +140,14 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
"BECWaveform": BECWaveform,
"BECImageShow": BECImageShow,
"BECMotorMap": BECMotorMap,
+ "BECMultiWaveform": BECMultiWaveform,
+ }
+ widget_method_map = {
+ "BECWaveform": "plot",
+ "BECImageShow": "image",
+ "BECMotorMap": "motor_map",
+ "BECMultiWaveform": "multi_waveform",
}
- widget_method_map = {"BECWaveform": "plot", "BECImageShow": "image", "BECMotorMap": "motor_map"}
clean_signal = pyqtSignal()
@@ -445,10 +457,27 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
return motor_map
+ def multi_waveform(
+ self,
+ monitor: str = None,
+ new: bool = False,
+ row: int | None = None,
+ col: int | None = None,
+ config: dict | None = None,
+ **axis_kwargs,
+ ):
+ multi_waveform = self.subplot_factory(
+ widget_type="BECMultiWaveform", config=config, row=row, col=col, new=new, **axis_kwargs
+ )
+ if config is not None:
+ return multi_waveform
+ multi_waveform.set_monitor(monitor)
+ return multi_waveform
+
def subplot_factory(
self,
widget_type: Literal[
- "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
+ "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
row: int = None,
col: int = None,
@@ -500,7 +529,7 @@ class BECFigure(BECWidget, pg.GraphicsLayoutWidget):
def add_widget(
self,
widget_type: Literal[
- "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap"
+ "BECPlotBase", "BECWaveform", "BECImageShow", "BECMotorMap", "BECMultiWaveform"
] = "BECPlotBase",
widget_id: str = None,
row: int = None,
diff --git a/bec_widgets/widgets/figure/plots/multi_waveform/__init__.py b/bec_widgets/widgets/figure/plots/multi_waveform/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/figure/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/figure/plots/multi_waveform/multi_waveform.py
new file mode 100644
index 00000000..451c76d4
--- /dev/null
+++ b/bec_widgets/widgets/figure/plots/multi_waveform/multi_waveform.py
@@ -0,0 +1,330 @@
+from collections import deque
+from typing import Literal, Optional
+
+import pyqtgraph as pg
+from bec_lib.endpoints import MessageEndpoints
+from bec_lib.logger import bec_logger
+from pydantic import Field, field_validator
+from pyqtgraph.exporters import MatplotlibExporter
+from qtpy.QtCore import Signal, Slot
+from qtpy.QtWidgets import QWidget
+
+from bec_widgets.utils import Colors
+from bec_widgets.widgets.figure.plots.plot_base import BECPlotBase, SubplotConfig
+
+logger = bec_logger.logger
+
+
+class BECMultiWaveformConfig(SubplotConfig):
+ color_palette: Optional[str] = Field(
+ "magma", description="The color palette of the figure widget.", validate_default=True
+ )
+ curve_limit: Optional[int] = Field(
+ 200, description="The maximum number of curves to display on the plot."
+ )
+ flush_buffer: Optional[bool] = Field(
+ False, description="Flush the buffer of the plot widget when the curve limit is reached."
+ )
+ monitor: Optional[str] = Field(
+ None, description="The monitor to set for the plot widget."
+ ) # TODO validate monitor in bec -> maybe make it as SignalData class for validation purpose
+ curve_width: Optional[int] = Field(1, description="The width of the curve on the plot.")
+ opacity: Optional[int] = Field(50, description="The opacity of the curve on the plot.")
+ highlight_last_curve: Optional[bool] = Field(
+ True, description="Highlight the last curve on the plot."
+ )
+
+ model_config: dict = {"validate_assignment": True}
+ _validate_color_map_z = field_validator("color_palette")(Colors.validate_color_map)
+
+
+class BECMultiWaveform(BECPlotBase):
+ monitor_signal_updated = Signal()
+ USER_ACCESS = [
+ "_rpc_id",
+ "_config_dict",
+ "curves",
+ "set_monitor",
+ "set_opacity",
+ "set_curve_limit",
+ "set_curve_highlight",
+ "set_colormap",
+ "set",
+ "set_title",
+ "set_x_label",
+ "set_y_label",
+ "set_x_scale",
+ "set_y_scale",
+ "set_x_lim",
+ "set_y_lim",
+ "set_grid",
+ "set_colormap",
+ "enable_fps_monitor",
+ "lock_aspect_ratio",
+ "export",
+ "get_all_data",
+ "remove",
+ ]
+
+ def __init__(
+ self,
+ parent: Optional[QWidget] = None,
+ parent_figure=None,
+ config: Optional[BECMultiWaveformConfig] = None,
+ client=None,
+ gui_id: Optional[str] = None,
+ ):
+ if config is None:
+ config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
+ super().__init__(
+ parent=parent, parent_figure=parent_figure, config=config, client=client, gui_id=gui_id
+ )
+ self.old_scan_id = None
+ self.scan_id = None
+ self.monitor = None
+ self.connected = False
+ self.current_highlight_index = 0
+ self._curves = deque()
+ self.number_of_visible_curves = 0
+
+ # Get bec shortcuts dev, scans, queue, scan_storage, dap
+ self.get_bec_shortcuts()
+
+ @property
+ def curves(self) -> deque:
+ """
+ Get the curves of the plot widget as a deque.
+ Returns:
+ deque: Deque of curves.
+ """
+ return self._curves
+
+ @curves.setter
+ def curves(self, value: deque):
+ self._curves = value
+
+ @property
+ def highlight_last_curve(self) -> bool:
+ """
+ Get the highlight_last_curve property.
+ Returns:
+ bool: The highlight_last_curve property.
+ """
+ return self.config.highlight_last_curve
+
+ @highlight_last_curve.setter
+ def highlight_last_curve(self, value: bool):
+ self.config.highlight_last_curve = value
+
+ def set_monitor(self, monitor: str):
+ """
+ Set the monitor for the plot widget.
+ Args:
+ monitor (str): The monitor to set.
+ """
+ self.config.monitor = monitor
+ self._connect_monitor()
+
+ def _connect_monitor(self):
+ """
+ Connect the monitor to the plot widget.
+ """
+ try:
+ previous_monitor = self.monitor
+ except AttributeError:
+ previous_monitor = None
+
+ if previous_monitor and self.connected is True:
+ self.bec_dispatcher.disconnect_slot(
+ self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(previous_monitor)
+ )
+ if self.config.monitor and self.connected is False:
+ self.bec_dispatcher.connect_slot(
+ self.on_monitor_1d_update, MessageEndpoints.device_monitor_1d(self.config.monitor)
+ )
+ self.connected = True
+ self.monitor = self.config.monitor
+
+ @Slot(dict, dict)
+ def on_monitor_1d_update(self, msg: dict, metadata: dict):
+ """
+ Update the plot widget with the monitor data.
+
+ Args:
+ msg(dict): The message data.
+ metadata(dict): The metadata of the message.
+ """
+ data = msg.get("data", None)
+ current_scan_id = metadata.get("scan_id", None)
+
+ if current_scan_id != self.scan_id:
+ self.scan_id = current_scan_id
+ self.plot_item.clear()
+ self.curves.clear()
+
+ # Always create a new curve and add it
+ curve = pg.PlotDataItem()
+ curve.setData(data)
+ self.plot_item.addItem(curve)
+ self.curves.append(curve)
+
+ # Max Trace and scale colors
+ self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
+
+ self.monitor_signal_updated.emit()
+
+ @Slot(int)
+ def set_curve_highlight(self, index: int):
+ """
+ Set the curve highlight based on visible curves.
+
+ Args:
+ index (int): The index of the curve to highlight among visible curves.
+ """
+ visible_curves = [curve for curve in self.curves if curve.isVisible()]
+ num_visible_curves = len(visible_curves)
+ self.number_of_visible_curves = num_visible_curves
+
+ if num_visible_curves == 0:
+ return # No curves to highlight
+
+ if index >= num_visible_curves:
+ index = num_visible_curves - 1
+ elif index < 0:
+ index = num_visible_curves + index
+ self.current_highlight_index = index
+ num_colors = num_visible_curves
+ colors = Colors.evenly_spaced_colors(
+ colormap=self.config.color_palette, num=num_colors, format="HEX"
+ )
+ for i, curve in enumerate(visible_curves):
+ curve.setPen()
+ if i == self.current_highlight_index:
+ curve.setPen(pg.mkPen(color=colors[i], width=5))
+ curve.setAlpha(alpha=1, auto=False)
+ curve.setZValue(1)
+ else:
+ curve.setPen(pg.mkPen(color=colors[i], width=1))
+ curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
+ curve.setZValue(0)
+
+ @Slot(int)
+ def set_opacity(self, opacity: int):
+ """
+ Set the opacity of the curve on the plot.
+
+ Args:
+ opacity(int): The opacity of the curve. 0-100.
+ """
+ self.config.opacity = max(0, min(100, opacity))
+ self.set_curve_highlight(self.current_highlight_index)
+
+ @Slot(int, bool)
+ def set_curve_limit(self, max_trace: int, flush_buffer: bool = False):
+ """
+ Set the maximum number of traces to display on the plot.
+
+ Args:
+ max_trace (int): The maximum number of traces to display.
+ flush_buffer (bool): Flush the buffer.
+ """
+ self.config.curve_limit = max_trace
+ self.config.flush_buffer = flush_buffer
+
+ if self.config.curve_limit is None:
+ self.scale_colors()
+ return
+
+ if self.config.flush_buffer:
+ # Remove excess curves from the plot and the deque
+ while len(self.curves) > self.config.curve_limit:
+ curve = self.curves.popleft()
+ self.plot_item.removeItem(curve)
+ else:
+ # Hide or show curves based on the new max_trace
+ num_curves_to_show = min(self.config.curve_limit, len(self.curves))
+ for i, curve in enumerate(self.curves):
+ if i < len(self.curves) - num_curves_to_show:
+ curve.hide()
+ else:
+ curve.show()
+ self.scale_colors()
+
+ def scale_colors(self):
+ """
+ Scale the colors of the curves based on the current colormap.
+ """
+ if self.config.highlight_last_curve:
+ self.set_curve_highlight(-1) # Use -1 to highlight the last visible curve
+ else:
+ self.set_curve_highlight(self.current_highlight_index)
+
+ def set_colormap(self, colormap: str):
+ """
+ Set the colormap for the curves.
+
+ Args:
+ colormap(str): Colormap for the curves.
+ """
+ self.config.color_palette = colormap
+ self.set_curve_highlight(self.current_highlight_index)
+
+ def get_all_data(self, output: Literal["dict", "pandas"] = "dict") -> dict:
+ """
+ Extract all curve data into a dictionary or a pandas DataFrame.
+
+ Args:
+ output (Literal["dict", "pandas"]): Format of the output data.
+
+ Returns:
+ dict | pd.DataFrame: Data of all curves in the specified format.
+ """
+ data = {}
+ try:
+ import pandas as pd
+ except ImportError:
+ pd = None
+ if output == "pandas":
+ logger.warning(
+ "Pandas is not installed. "
+ "Please install pandas using 'pip install pandas'."
+ "Output will be dictionary instead."
+ )
+ output = "dict"
+
+ curve_keys = []
+ curves_list = list(self.curves)
+ for i, curve in enumerate(curves_list):
+ x_data, y_data = curve.getData()
+ if x_data is not None or y_data is not None:
+ key = f"curve_{i}"
+ curve_keys.append(key)
+ if output == "dict":
+ data[key] = {"x": x_data.tolist(), "y": y_data.tolist()}
+ elif output == "pandas" and pd is not None:
+ data[key] = pd.DataFrame({"x": x_data, "y": y_data})
+
+ if output == "pandas" and pd is not None:
+ combined_data = pd.concat([data[key] for key in curve_keys], axis=1, keys=curve_keys)
+ return combined_data
+ return data
+
+ def export_to_matplotlib(self):
+ """
+ Export current waveform to matplotlib GUI. Available only if matplotlib is installed in the environment.
+ """
+ MatplotlibExporter(self.plot_item).export()
+
+
+if __name__ == "__main__":
+ import sys
+
+ from qtpy.QtWidgets import QApplication
+
+ from bec_widgets.widgets.figure import BECFigure
+
+ app = QApplication(sys.argv)
+ widget = BECFigure()
+ widget.multi_waveform(monitor="waveform")
+ widget.show()
+ sys.exit(app.exec_())
diff --git a/bec_widgets/widgets/multi_waveform/__init__.py b/bec_widgets/widgets/multi_waveform/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget.pyproject b/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget.pyproject
new file mode 100644
index 00000000..76c159ae
--- /dev/null
+++ b/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget.pyproject
@@ -0,0 +1 @@
+{'files': ['multi_waveform_widget.py','multi-waveform_controls.ui']}
\ No newline at end of file
diff --git a/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget_plugin.py b/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget_plugin.py
new file mode 100644
index 00000000..8a63e5ad
--- /dev/null
+++ b/bec_widgets/widgets/multi_waveform/bec_multi_waveform_widget_plugin.py
@@ -0,0 +1,54 @@
+# Copyright (C) 2022 The Qt Company Ltd.
+# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
+
+from qtpy.QtDesigner import QDesignerCustomWidgetInterface
+
+from bec_widgets.utils.bec_designer import designer_material_icon
+from bec_widgets.widgets.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class BECMultiWaveformWidgetPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ t = BECMultiWaveformWidget(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return "BEC Plots"
+
+ def icon(self):
+ return designer_material_icon(BECMultiWaveformWidget.ICON_NAME)
+
+ def includeFile(self):
+ return "bec_multi_waveform_widget"
+
+ def initialize(self, form_editor):
+ self._form_editor = form_editor
+
+ def isContainer(self):
+ return False
+
+ def isInitialized(self):
+ return self._form_editor is not None
+
+ def name(self):
+ return "BECMultiWaveformWidget"
+
+ def toolTip(self):
+ return "BECMultiWaveformWidget"
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/bec_widgets/widgets/multi_waveform/multi_waveform_controls.ui b/bec_widgets/widgets/multi_waveform/multi_waveform_controls.ui
new file mode 100644
index 00000000..516631bd
--- /dev/null
+++ b/bec_widgets/widgets/multi_waveform/multi_waveform_controls.ui
@@ -0,0 +1,99 @@
+
+
+ Form
+
+
+
+ 0
+ 0
+ 561
+ 86
+
+
+
+ Form
+
+
+ -
+
+
+ Curve Index
+
+
+
+ -
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ -
+
+
+ -
+
+
+ Highlight always last curve
+
+
+
+ -
+
+
+ Opacity
+
+
+
+ -
+
+
+ 100
+
+
+ Qt::Orientation::Horizontal
+
+
+
+ -
+
+
+ Max Trace
+
+
+
+ -
+
+
+ How many curves should be displayed
+
+
+ 500
+
+
+ 200
+
+
+
+ -
+
+
+ If hiddne curves should be deleted.
+
+
+ Flush Buffer
+
+
+
+ -
+
+
+ 100
+
+
+
+
+
+
+
+
diff --git a/bec_widgets/widgets/multi_waveform/multi_waveform_widget.py b/bec_widgets/widgets/multi_waveform/multi_waveform_widget.py
new file mode 100644
index 00000000..46d58057
--- /dev/null
+++ b/bec_widgets/widgets/multi_waveform/multi_waveform_widget.py
@@ -0,0 +1,533 @@
+import os
+from typing import Literal
+
+import pyqtgraph as pg
+from bec_lib.device import ReadoutPriority
+from bec_lib.logger import bec_logger
+from qtpy.QtCore import Slot
+from qtpy.QtWidgets import QVBoxLayout, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.settings_dialog import SettingsDialog
+from bec_widgets.qt_utils.toolbar import (
+ DeviceSelectionAction,
+ MaterialIconAction,
+ ModularToolBar,
+ SeparatorAction,
+ WidgetAction,
+)
+from bec_widgets.utils import UILoader
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.widgets.base_classes.device_input_base import BECDeviceFilter
+from bec_widgets.widgets.colormap_widget.colormap_widget import BECColorMapWidget
+from bec_widgets.widgets.device_combobox.device_combobox import DeviceComboBox
+from bec_widgets.widgets.figure import BECFigure
+from bec_widgets.widgets.figure.plots.axis_settings import AxisSettings
+from bec_widgets.widgets.figure.plots.multi_waveform.multi_waveform import BECMultiWaveformConfig
+
+logger = bec_logger.logger
+
+
+class BECMultiWaveformWidget(BECWidget, QWidget):
+ ICON_NAME = "ssid_chart"
+ USER_ACCESS = [
+ "curves",
+ "set_monitor",
+ "set_curve_highlight",
+ "set_opacity",
+ "set_curve_limit",
+ "set_buffer_flush",
+ "set_highlight_last_curve",
+ "set_colormap",
+ "set",
+ "set_title",
+ "set_x_label",
+ "set_y_label",
+ "set_x_scale",
+ "set_y_scale",
+ "set_x_lim",
+ "set_y_lim",
+ "set_grid",
+ "set_colormap",
+ "enable_fps_monitor",
+ "lock_aspect_ratio",
+ "get_all_data",
+ "export",
+ ]
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ config: BECMultiWaveformConfig | dict = None,
+ client=None,
+ gui_id: str | None = None,
+ ) -> None:
+ if config is None:
+ config = BECMultiWaveformConfig(widget_class=self.__class__.__name__)
+ else:
+ if isinstance(config, dict):
+ config = BECMultiWaveformConfig(**config)
+ super().__init__(client=client, gui_id=gui_id)
+ QWidget.__init__(self, parent)
+
+ self.layout = QVBoxLayout(self)
+ self.layout.setSpacing(0)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+
+ self.fig = BECFigure()
+ self.colormap_button = BECColorMapWidget(cmap="magma")
+ self.toolbar = ModularToolBar(
+ actions={
+ "monitor": DeviceSelectionAction(
+ "",
+ DeviceComboBox(
+ device_filter=BECDeviceFilter.DEVICE,
+ readout_priority_filter=ReadoutPriority.ASYNC,
+ ),
+ ),
+ "connect": MaterialIconAction(icon_name="link", tooltip="Connect Device"),
+ "separator_0": SeparatorAction(),
+ "colormap": WidgetAction(widget=self.colormap_button),
+ "separator_1": SeparatorAction(),
+ "save": MaterialIconAction(icon_name="save", tooltip="Open Export Dialog"),
+ "matplotlib": MaterialIconAction(
+ icon_name="photo_library", tooltip="Open Matplotlib Plot"
+ ),
+ "separator_2": 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"
+ ),
+ "crosshair": MaterialIconAction(
+ icon_name="point_scan", tooltip="Show Crosshair", checkable=True
+ ),
+ "separator_3": SeparatorAction(),
+ "fps_monitor": MaterialIconAction(
+ icon_name="speed", tooltip="Show FPS Monitor", checkable=True
+ ),
+ "axis_settings": MaterialIconAction(
+ icon_name="settings", tooltip="Open Configuration Dialog"
+ ),
+ },
+ target_widget=self,
+ )
+
+ self.layout.addWidget(self.toolbar)
+ self.layout.addWidget(self.fig)
+
+ self.waveform = self.fig.multi_waveform() # FIXME config should be injected here
+ self.config = config
+
+ self.create_multi_waveform_controls()
+
+ self._hook_actions()
+ self.waveform.monitor_signal_updated.connect(self.update_controls_limits)
+
+ def create_multi_waveform_controls(self):
+ """
+ Create the controls for the multi waveform widget.
+ """
+ current_path = os.path.dirname(__file__)
+ self.controls = UILoader(self).loader(
+ os.path.join(current_path, "multi_waveform_controls.ui")
+ )
+ self.layout.addWidget(self.controls)
+
+ # Hook default controls properties
+ self.controls.checkbox_highlight.setChecked(self.config.highlight_last_curve)
+ self.controls.spinbox_opacity.setValue(self.config.opacity)
+ self.controls.slider_opacity.setValue(self.config.opacity)
+ self.controls.spinbox_max_trace.setValue(self.config.curve_limit)
+ self.controls.checkbox_flush_buffer.setChecked(self.config.flush_buffer)
+
+ # Connect signals
+ self.controls.spinbox_max_trace.valueChanged.connect(self.set_curve_limit)
+ self.controls.checkbox_flush_buffer.toggled.connect(self.set_buffer_flush)
+ self.controls.slider_opacity.valueChanged.connect(self.controls.spinbox_opacity.setValue)
+ self.controls.spinbox_opacity.valueChanged.connect(self.controls.slider_opacity.setValue)
+ self.controls.slider_opacity.valueChanged.connect(self.set_opacity)
+ self.controls.spinbox_opacity.valueChanged.connect(self.set_opacity)
+ self.controls.slider_index.valueChanged.connect(self.controls.spinbox_index.setValue)
+ self.controls.spinbox_index.valueChanged.connect(self.controls.slider_index.setValue)
+ self.controls.slider_index.valueChanged.connect(self.set_curve_highlight)
+ self.controls.spinbox_index.valueChanged.connect(self.set_curve_highlight)
+ self.controls.checkbox_highlight.toggled.connect(self.set_highlight_last_curve)
+
+ # Trigger first round of settings
+ self.set_curve_limit(self.config.curve_limit)
+ self.set_opacity(self.config.opacity)
+ self.set_highlight_last_curve(self.config.highlight_last_curve)
+
+ @Slot()
+ def update_controls_limits(self):
+ """
+ Update the limits of the controls.
+ """
+ num_curves = len(self.waveform.curves)
+ if num_curves == 0:
+ num_curves = 1 # Avoid setting max to 0
+ current_index = num_curves - 1
+ self.controls.slider_index.setMinimum(0)
+ self.controls.slider_index.setMaximum(self.waveform.number_of_visible_curves - 1)
+ self.controls.spinbox_index.setMaximum(self.waveform.number_of_visible_curves - 1)
+ if self.controls.checkbox_highlight.isChecked():
+ self.controls.slider_index.setValue(current_index)
+ self.controls.spinbox_index.setValue(current_index)
+
+ def _hook_actions(self):
+ self.toolbar.widgets["connect"].action.triggered.connect(self._connect_action)
+ # Separator 0
+ self.toolbar.widgets["save"].action.triggered.connect(self.export)
+ self.toolbar.widgets["matplotlib"].action.triggered.connect(self.export_to_matplotlib)
+ self.toolbar.widgets["colormap"].widget.colormap_changed_signal.connect(self.set_colormap)
+ # Separator 1
+ 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["crosshair"].action.triggered.connect(self.waveform.toggle_crosshair)
+ # Separator 2
+ self.toolbar.widgets["fps_monitor"].action.triggered.connect(self.enable_fps_monitor)
+ self.toolbar.widgets["axis_settings"].action.triggered.connect(self.show_axis_settings)
+
+ ###################################
+ # Dialog Windows
+ ###################################
+ @SafeSlot(popup_error=True)
+ def _connect_action(self):
+ monitor_combo = self.toolbar.widgets["monitor"].device_combobox
+ monitor_name = monitor_combo.currentText()
+ self.set_monitor(monitor=monitor_name)
+ monitor_combo.setStyleSheet("QComboBox { background-color: " "; }")
+
+ def show_axis_settings(self):
+ dialog = SettingsDialog(
+ self,
+ settings_widget=AxisSettings(),
+ window_title="Axis Settings",
+ config=self.waveform._config_dict["axis"],
+ )
+ dialog.exec()
+
+ ########################################
+ # User Access Methods from MultiWaveform
+ ########################################
+ @property
+ def curves(self) -> list[pg.PlotDataItem]:
+ """
+ Get the curves of the plot widget as a list
+ Returns:
+ list: List of curves.
+ """
+ return list(self.waveform.curves)
+
+ @curves.setter
+ def curves(self, value: list[pg.PlotDataItem]):
+ self.waveform.curves = value
+
+ @SafeSlot(popup_error=True)
+ def set_monitor(self, monitor: str) -> None:
+ """
+ Set the monitor of the plot widget.
+
+ Args:
+ monitor(str): The monitor to set.
+ """
+ self.waveform.set_monitor(monitor)
+ if self.toolbar.widgets["monitor"].device_combobox.currentText() != monitor:
+ self.toolbar.widgets["monitor"].device_combobox.setCurrentText(monitor)
+ self.toolbar.widgets["monitor"].device_combobox.setStyleSheet(
+ "QComboBox { background-color: " "; }"
+ )
+
+ @SafeSlot(int)
+ def set_curve_highlight(self, index: int) -> None:
+ """
+ Set the curve highlight of the plot widget by index
+
+ Args:
+ index(int): The index of the curve to highlight.
+ """
+ if self.controls.checkbox_highlight.isChecked():
+ # If always highlighting the last curve, set index to -1
+ self.waveform.set_curve_highlight(-1)
+ else:
+ self.waveform.set_curve_highlight(index)
+
+ @SafeSlot(int)
+ def set_opacity(self, opacity: int) -> None:
+ """
+ Set the opacity of the plot widget.
+
+ Args:
+ opacity(int): The opacity to set.
+ """
+ self.waveform.set_opacity(opacity)
+
+ @SafeSlot(int)
+ def set_curve_limit(self, curve_limit: int) -> None:
+ """
+ Set the maximum number of traces to display on the plot widget.
+
+ Args:
+ curve_limit(int): The maximum number of traces to display.
+ """
+ flush_buffer = self.controls.checkbox_flush_buffer.isChecked()
+ self.waveform.set_curve_limit(curve_limit, flush_buffer)
+ self.update_controls_limits()
+
+ @SafeSlot(bool)
+ def set_buffer_flush(self, flush_buffer: bool) -> None:
+ """
+ Set the buffer flush property of the plot widget.
+
+ Args:
+ flush_buffer(bool): True to flush the buffer, False to not flush the buffer.
+ """
+ curve_limit = self.controls.spinbox_max_trace.value()
+ self.waveform.set_curve_limit(curve_limit, flush_buffer)
+ self.update_controls_limits()
+
+ @SafeSlot(bool)
+ def set_highlight_last_curve(self, enable: bool) -> None:
+ """
+ Enable or disable highlighting of the last curve.
+
+ Args:
+ enable(bool): True to enable highlighting of the last curve, False to disable.
+ """
+ self.waveform.config.highlight_last_curve = enable
+ if enable:
+ self.controls.slider_index.setEnabled(False)
+ self.controls.spinbox_index.setEnabled(False)
+ self.controls.checkbox_highlight.setChecked(True)
+ self.waveform.set_curve_highlight(-1)
+ else:
+ self.controls.slider_index.setEnabled(True)
+ self.controls.spinbox_index.setEnabled(True)
+ self.controls.checkbox_highlight.setChecked(False)
+ index = self.controls.spinbox_index.value()
+ self.waveform.set_curve_highlight(index)
+
+ @SafeSlot()
+ def set_colormap(self, colormap: str) -> None:
+ """
+ Set the colormap of the plot widget.
+
+ Args:
+ colormap(str): The colormap to set.
+ """
+ self.waveform.set_colormap(colormap)
+
+ ###################################
+ # 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): The title to set.
+ """
+ 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): The x-axis label to set.
+ """
+ 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): The y-axis label to set.
+ """
+ self.waveform.set_y_label(y_label)
+
+ def set_x_scale(self, x_scale: Literal["linear", "log"]):
+ """
+ Set the x-axis scale of the plot widget.
+
+ Args:
+ x_scale(str): The x-axis scale to set.
+ """
+ self.waveform.set_x_scale(x_scale)
+
+ def set_y_scale(self, y_scale: Literal["linear", "log"]):
+ """
+ Set the y-axis scale of the plot widget.
+
+ Args:
+ y_scale(str): The y-axis scale to set.
+ """
+ self.waveform.set_y_scale(y_scale)
+
+ def set_x_lim(self, x_lim: tuple):
+ """
+ Set x-axis limits of the plot widget.
+
+ Args:
+ x_lim(tuple): The x-axis limits to set.
+ """
+ self.waveform.set_x_lim(x_lim)
+
+ def set_y_lim(self, y_lim: tuple):
+ """
+ Set y-axis limits of the plot widget.
+
+ Args:
+ y_lim(tuple): The y-axis limits to set.
+ """
+ self.waveform.set_y_lim(y_lim)
+
+ def set_legend_label_size(self, legend_label_size: int):
+ """
+ Set the legend label size of the plot widget.
+
+ Args:
+ legend_label_size(int): The legend label size to set.
+ """
+ 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): True to enable auto range, False to disable.
+ axis(str): The axis to set the auto range for. Default is "xy".
+ """
+ self.waveform.set_auto_range(enabled, axis)
+
+ def enable_fps_monitor(self, enabled: bool):
+ """
+ Enable or disable the FPS monitor
+
+ Args:
+ enabled(bool): True to enable the FPS monitor, False to disable.
+ """
+ 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 of the plot widget.
+
+ Args:
+ x_grid(bool): True to enable the x-grid, False to disable.
+ y_grid(bool): True to enable the y-grid, False to disable.
+ """
+ self.waveform.set_grid(x_grid, y_grid)
+
+ def set_outer_axes(self, show: bool):
+ """
+ Set the outer axes of the plot widget.
+
+ Args:
+ show(bool): True to show the outer axes, False to hide.
+ """
+ self.waveform.set_outer_axes(show)
+
+ def lock_aspect_ratio(self, lock: bool):
+ """
+ Lock the aspect ratio of the plot widget.
+
+ Args:
+ lock(bool): True to lock the aspect ratio, False to unlock.
+ """
+ self.waveform.lock_aspect_ratio(lock)
+
+ @SafeSlot()
+ def enable_mouse_rectangle_mode(self):
+ """
+ Enable the mouse rectangle mode of the plot widget.
+ """
+ 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):
+ """
+ Enable the mouse pan mode of the plot widget.
+ """
+ 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):
+ """
+ Export 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 cleanup(self):
+ self.fig.cleanup()
+ return super().cleanup()
+
+
+if __name__ == "__main__": # pragma: no cover
+ import sys
+
+ from qtpy.QtWidgets import QApplication
+
+ app = QApplication(sys.argv)
+ widget = BECMultiWaveformWidget()
+ widget.show()
+ sys.exit(app.exec())
diff --git a/bec_widgets/widgets/multi_waveform/register_bec_multi_waveform_widget.py b/bec_widgets/widgets/multi_waveform/register_bec_multi_waveform_widget.py
new file mode 100644
index 00000000..d23b6999
--- /dev/null
+++ b/bec_widgets/widgets/multi_waveform/register_bec_multi_waveform_widget.py
@@ -0,0 +1,17 @@
+def main(): # pragma: no cover
+ from qtpy import PYSIDE6
+
+ if not PYSIDE6:
+ print("PYSIDE6 is not available in the environment. Cannot patch designer.")
+ return
+ from PySide6.QtDesigner import QPyDesignerCustomWidgetCollection
+
+ from bec_widgets.widgets.multi_waveform.bec_multi_waveform_widget_plugin import (
+ BECMultiWaveformWidgetPlugin,
+ )
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(BECMultiWaveformWidgetPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/tests/unit_tests/test_bec_dock.py b/tests/unit_tests/test_bec_dock.py
index 3fbd02af..82f0050d 100644
--- a/tests/unit_tests/test_bec_dock.py
+++ b/tests/unit_tests/test_bec_dock.py
@@ -65,16 +65,18 @@ def test_add_remove_bec_figure_to_dock(bec_dock_area):
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(fig.widgets) == 3
+ assert len(fig.widgets) == 4
assert fig.config.widget_class == "BECFigure"
assert plt.config.widget_class == "BECWaveform"
assert im.config.widget_class == "BECImageShow"
assert mm.config.widget_class == "BECMotorMap"
+ assert mw.config.widget_class == "BECMultiWaveform"
def test_close_docks(bec_dock_area, qtbot):
diff --git a/tests/unit_tests/test_bec_figure.py b/tests/unit_tests/test_bec_figure.py
index 36f91d2c..dd65b4ff 100644
--- a/tests/unit_tests/test_bec_figure.py
+++ b/tests/unit_tests/test_bec_figure.py
@@ -6,6 +6,7 @@ import pytest
from bec_widgets.widgets.figure import BECFigure
from bec_widgets.widgets.figure.plots.image.image import BECImageShow
from bec_widgets.widgets.figure.plots.motor_map.motor_map import BECMotorMap
+from bec_widgets.widgets.figure.plots.multi_waveform.multi_waveform import BECMultiWaveform
from bec_widgets.widgets.figure.plots.waveform.waveform import BECWaveform
from .client_mocks import mocked_client
@@ -63,10 +64,12 @@ def test_add_different_types_of_widgets(qtbot, mocked_client):
plt = bec_figure.plot(x_name="samx", y_name="bpm4i")
im = bec_figure.image("eiger")
motor_map = bec_figure.motor_map("samx", "samy")
+ multi_waveform = bec_figure.multi_waveform("waveform")
assert plt.__class__ == BECWaveform
assert im.__class__ == BECImageShow
assert motor_map.__class__ == BECMotorMap
+ assert multi_waveform.__class__ == BECMultiWaveform
def test_access_widgets_access_errors(qtbot, mocked_client):
diff --git a/tests/unit_tests/test_multi_waveform.py b/tests/unit_tests/test_multi_waveform.py
new file mode 100644
index 00000000..ad0c58d1
--- /dev/null
+++ b/tests/unit_tests/test_multi_waveform.py
@@ -0,0 +1,253 @@
+from unittest import mock
+
+import numpy as np
+import pytest
+from bec_lib.endpoints import messages
+
+from bec_widgets.utils import Colors
+from bec_widgets.widgets.figure import BECFigure
+
+from .client_mocks import mocked_client
+from .conftest import create_widget
+
+
+def test_set_monitor(qtbot, mocked_client):
+ """Test that setting the monitor connects the appropriate slot."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("waveform1d")
+
+ assert multi_waveform.config.monitor == "waveform1d"
+ assert multi_waveform.connected is True
+
+ data_0 = np.random.rand(100)
+ msg = messages.DeviceMonitor1DMessage(
+ device="waveform1d", data=data_0, metadata={"scan_id": "12345"}
+ )
+ multi_waveform.on_monitor_1d_update(msg.content, msg.metadata)
+ data_waveform = multi_waveform.get_all_data()
+ print(data_waveform)
+
+ assert len(data_waveform) == 1
+ assert np.array_equal(data_waveform["curve_0"]["y"], data_0)
+
+ data_1 = np.random.rand(100)
+ msg = messages.DeviceMonitor1DMessage(
+ device="waveform1d", data=data_1, metadata={"scan_id": "12345"}
+ )
+ multi_waveform.on_monitor_1d_update(msg.content, msg.metadata)
+
+ data_waveform = multi_waveform.get_all_data()
+ assert len(data_waveform) == 2
+ assert np.array_equal(data_waveform["curve_0"]["y"], data_0)
+ assert np.array_equal(data_waveform["curve_1"]["y"], data_1)
+
+
+def test_on_monitor_1d_update(qtbot, mocked_client):
+ """Test that data updates add curves to the plot."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("test_monitor")
+
+ # Simulate receiving data updates
+ test_data = np.array([1, 2, 3, 4, 5])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+
+ # Call the on_monitor_1d_update method
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Check that a curve has been added
+ assert len(multi_waveform.curves) == 1
+ # Check that the data in the curve is correct
+ curve = multi_waveform.curves[-1]
+ x_data, y_data = curve.getData()
+ assert np.array_equal(y_data, test_data)
+
+ # Simulate another data update
+ test_data_2 = np.array([6, 7, 8, 9, 10])
+ msg2 = {"data": test_data_2}
+ metadata2 = {"scan_id": "scan_1"}
+
+ multi_waveform.on_monitor_1d_update(msg2, metadata2)
+
+ # Check that another curve has been added
+ assert len(multi_waveform.curves) == 2
+ # Check that the data in the curve is correct
+ curve2 = multi_waveform.curves[-1]
+ x_data2, y_data2 = curve2.getData()
+ assert np.array_equal(y_data2, test_data_2)
+
+
+def test_set_curve_limit_no_flush(qtbot, mocked_client):
+ """Test set_curve_limit with flush_buffer=False."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("test_monitor")
+
+ # Simulate adding multiple curves
+ for i in range(5):
+ test_data = np.array([i, i + 1, i + 2])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Check that there are 5 curves
+ assert len(multi_waveform.curves) == 5
+ # Set curve limit to 3 with flush_buffer=False
+ multi_waveform.set_curve_limit(3, flush_buffer=False)
+
+ # Check that curves are hidden, but not removed
+ assert len(multi_waveform.curves) == 5
+ visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
+ assert len(visible_curves) == 3
+ # The first two curves should be hidden
+ assert not multi_waveform.curves[0].isVisible()
+ assert not multi_waveform.curves[1].isVisible()
+ assert multi_waveform.curves[2].isVisible()
+ assert multi_waveform.curves[3].isVisible()
+ assert multi_waveform.curves[4].isVisible()
+
+
+def test_set_curve_limit_flush(qtbot, mocked_client):
+ """Test set_curve_limit with flush_buffer=True."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("test_monitor")
+
+ # Simulate adding multiple curves
+ for i in range(5):
+ test_data = np.array([i, i + 1, i + 2])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Check that there are 5 curves
+ assert len(multi_waveform.curves) == 5
+ # Set curve limit to 3 with flush_buffer=True
+ multi_waveform.set_curve_limit(3, flush_buffer=True)
+
+ # Check that only 3 curves remain
+ assert len(multi_waveform.curves) == 3
+ # The curves should be the last 3 added
+ x_data, y_data = multi_waveform.curves[0].getData()
+ assert np.array_equal(y_data, [2, 3, 4])
+ x_data, y_data = multi_waveform.curves[1].getData()
+ assert np.array_equal(y_data, [3, 4, 5])
+ x_data, y_data = multi_waveform.curves[2].getData()
+ assert np.array_equal(y_data, [4, 5, 6])
+
+
+def test_set_curve_highlight(qtbot, mocked_client):
+ """Test that the correct curve is highlighted."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("test_monitor")
+
+ # Simulate adding multiple curves
+ for i in range(3):
+ test_data = np.array([i, i + 1, i + 2])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Set highlight_last_curve to False
+ multi_waveform.highlight_last_curve = False
+ multi_waveform.set_curve_highlight(1) # Highlight the second curve (index 1)
+
+ # Check that the second curve is highlighted
+ visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
+ # Reverse the list to match indexing in set_curve_highlight
+ visible_curves = list(reversed(visible_curves))
+ for i, curve in enumerate(visible_curves):
+ pen = curve.opts["pen"]
+ width = pen.width()
+ if i == 1:
+ # Highlighted curve should have width 5
+ assert width == 5
+ else:
+ assert width == 1
+
+
+def test_set_opacity(qtbot, mocked_client):
+ """Test that setting opacity updates the curves."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("waveform1d")
+
+ # Simulate adding a curve
+ test_data = np.array([1, 2, 3])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Set opacity to 30
+ multi_waveform.set_opacity(30)
+ assert multi_waveform.config.opacity == 30
+
+
+def test_set_colormap(qtbot, mocked_client):
+ """Test that setting the colormap updates the curve colors."""
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("waveform1d")
+
+ # Simulate adding multiple curves
+ for i in range(3):
+ test_data = np.array([i, i + 1, i + 2])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Set a new colormap
+ multi_waveform.set_opacity(100)
+ multi_waveform.set_colormap("viridis")
+ # Check that the colors of the curves have changed accordingly
+ visible_curves = [curve for curve in multi_waveform.curves if curve.isVisible()]
+ # Get the colors applied
+ colors = Colors.evenly_spaced_colors(colormap="viridis", num=len(visible_curves), format="HEX")
+ for i, curve in enumerate(visible_curves):
+ pen = curve.opts["pen"]
+ pen_color = pen.color().name()
+ expected_color = colors[i]
+ # Compare pen color to expected color
+ assert pen_color.lower() == expected_color.lower()
+
+
+def test_export_to_matplotlib(qtbot, mocked_client):
+ """Test that export_to_matplotlib can be called without errors."""
+ try:
+ import matplotlib
+ except ImportError:
+ pytest.skip("Matplotlib not installed")
+
+ # Create a BECFigure
+ bec_figure = create_widget(qtbot, BECFigure, client=mocked_client)
+ # Add a multi_waveform plot
+ multi_waveform = bec_figure.multi_waveform()
+ multi_waveform.set_monitor("test_monitor")
+
+ # Simulate adding a curve
+ test_data = np.array([1, 2, 3])
+ msg = {"data": test_data}
+ metadata = {"scan_id": "scan_1"}
+ multi_waveform.on_monitor_1d_update(msg, metadata)
+
+ # Call export_to_matplotlib
+ with mock.patch("pyqtgraph.exporters.MatplotlibExporter.export") as mock_export:
+ multi_waveform.export_to_matplotlib()
+ mock_export.assert_called_once()
diff --git a/tests/unit_tests/test_multi_waveform_widget.py b/tests/unit_tests/test_multi_waveform_widget.py
new file mode 100644
index 00000000..ce61a90c
--- /dev/null
+++ b/tests/unit_tests/test_multi_waveform_widget.py
@@ -0,0 +1,295 @@
+from unittest.mock import MagicMock, patch
+
+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.widgets.figure.plots.axis_settings import AxisSettings
+from bec_widgets.widgets.multi_waveform.multi_waveform_widget import BECMultiWaveformWidget
+
+from .client_mocks import mocked_client
+
+
+@pytest.fixture
+def multi_waveform_widget(qtbot, mocked_client):
+ widget = BECMultiWaveformWidget(client=mocked_client())
+ qtbot.addWidget(widget)
+ qtbot.waitExposed(widget)
+ return widget
+
+
+@pytest.fixture
+def mock_waveform(multi_waveform_widget):
+ waveform_mock = MagicMock()
+ multi_waveform_widget.waveform = waveform_mock
+ return waveform_mock
+
+
+def test_multi_waveform_widget_init(multi_waveform_widget):
+ assert multi_waveform_widget is not None
+ assert multi_waveform_widget.client is not None
+ assert isinstance(multi_waveform_widget, BECMultiWaveformWidget)
+ assert multi_waveform_widget.config.widget_class == "BECMultiWaveformWidget"
+
+
+###################################
+# Wrapper methods for Waveform
+###################################
+
+
+def test_multi_waveform_widget_set_monitor(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_monitor("waveform1d")
+ mock_waveform.set_monitor.assert_called_once_with("waveform1d")
+
+
+def test_multi_waveform_widget_set_curve_highlight_last_active(
+ multi_waveform_widget, mock_waveform
+):
+ multi_waveform_widget.set_curve_highlight(1)
+ mock_waveform.set_curve_highlight.assert_called_once_with(-1)
+
+
+def test_multi_waveform_widget_set_curve_highlight_last_not_active(
+ multi_waveform_widget, mock_waveform
+):
+ multi_waveform_widget.set_highlight_last_curve(False)
+ multi_waveform_widget.set_curve_highlight(1)
+ mock_waveform.set_curve_highlight.assert_called_with(1)
+
+
+def test_multi_waveform_widget_set_opacity(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_opacity(50)
+ mock_waveform.set_opacity.assert_called_once_with(50)
+
+
+def test_multi_waveform_widget_set_curve_limit(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_curve_limit(10)
+ mock_waveform.set_curve_limit.assert_called_once_with(
+ 10, multi_waveform_widget.controls.checkbox_flush_buffer.isChecked()
+ )
+
+
+def test_multi_waveform_widget_set_buffer_flush(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_buffer_flush(True)
+ mock_waveform.set_curve_limit.assert_called_once_with(
+ multi_waveform_widget.controls.spinbox_max_trace.value(), True
+ )
+
+
+def test_multi_waveform_widget_set_highlight_last_curve(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_highlight_last_curve(True)
+ assert multi_waveform_widget.waveform.config.highlight_last_curve is True
+ assert not multi_waveform_widget.controls.slider_index.isEnabled()
+ assert not multi_waveform_widget.controls.spinbox_index.isEnabled()
+ mock_waveform.set_curve_highlight.assert_called_once_with(-1)
+
+
+def test_multi_waveform_widget_set_colormap(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.set_colormap("viridis")
+ mock_waveform.set_colormap.assert_called_once_with("viridis")
+
+
+def test_multi_waveform_widget_set_base(multi_waveform_widget, mock_waveform):
+ multi_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),
+ )
+ mock_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),
+ )
+
+
+###################################
+# Toolbar interactions
+###################################
+
+
+def test_toolbar_connect_action_triggered(multi_waveform_widget, qtbot):
+ action_connect = multi_waveform_widget.toolbar.widgets["connect"].action
+ device_combobox = multi_waveform_widget.toolbar.widgets["monitor"].device_combobox
+ device_combobox.addItem("test_monitor")
+ device_combobox.setCurrentText("test_monitor")
+
+ with patch.object(multi_waveform_widget, "set_monitor") as mock_set_monitor:
+ action_connect.trigger()
+ mock_set_monitor.assert_called_once_with(monitor="test_monitor")
+
+
+def test_toolbar_drag_mode_action_triggered(multi_waveform_widget, qtbot):
+ action_drag = multi_waveform_widget.toolbar.widgets["drag_mode"].action
+ action_rectangle = multi_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(multi_waveform_widget, qtbot):
+ action_drag = multi_waveform_widget.toolbar.widgets["drag_mode"].action
+ action_rectangle = multi_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(multi_waveform_widget, mock_waveform, qtbot):
+ action = multi_waveform_widget.toolbar.widgets["auto_range"].action
+ action.trigger()
+ qtbot.wait(200)
+ mock_waveform.set_auto_range.assert_called_once_with(True, "xy")
+
+
+###################################
+# Control Panel interactions
+###################################
+
+
+def test_controls_opacity_slider(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.controls.slider_opacity.setValue(75)
+ mock_waveform.set_opacity.assert_called_with(75)
+ assert multi_waveform_widget.controls.spinbox_opacity.value() == 75
+
+
+def test_controls_opacity_spinbox(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.controls.spinbox_opacity.setValue(25)
+ mock_waveform.set_opacity.assert_called_with(25)
+ assert multi_waveform_widget.controls.slider_opacity.value() == 25
+
+
+def test_controls_max_trace_spinbox(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.controls.spinbox_max_trace.setValue(15)
+ mock_waveform.set_curve_limit.assert_called_with(
+ 15, multi_waveform_widget.controls.checkbox_flush_buffer.isChecked()
+ )
+
+
+def test_controls_flush_buffer_checkbox(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.controls.checkbox_flush_buffer.setChecked(True)
+ mock_waveform.set_curve_limit.assert_called_with(
+ multi_waveform_widget.controls.spinbox_max_trace.value(), True
+ )
+
+
+def test_controls_highlight_checkbox(multi_waveform_widget, mock_waveform):
+ multi_waveform_widget.controls.checkbox_highlight.setChecked(False)
+ assert multi_waveform_widget.waveform.config.highlight_last_curve is False
+ assert multi_waveform_widget.controls.slider_index.isEnabled()
+ assert multi_waveform_widget.controls.spinbox_index.isEnabled()
+ index = multi_waveform_widget.controls.spinbox_index.value()
+ mock_waveform.set_curve_highlight.assert_called_with(index)
+
+
+###################################
+# Axis Settings Dialog Tests
+###################################
+
+
+def show_axis_dialog(qtbot, multi_waveform_widget):
+ axis_dialog = SettingsDialog(
+ multi_waveform_widget,
+ settings_widget=AxisSettings(),
+ window_title="Axis Settings",
+ config=multi_waveform_widget.waveform._config_dict["axis"],
+ )
+ qtbot.addWidget(axis_dialog)
+ qtbot.waitExposed(axis_dialog)
+ return axis_dialog
+
+
+def test_axis_dialog_with_axis_limits(qtbot, multi_waveform_widget):
+ multi_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, multi_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_set_properties(qtbot, multi_waveform_widget):
+ axis_dialog = show_axis_dialog(qtbot, multi_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 multi_waveform_widget.waveform.config.axis.title == "New Title"
+ assert multi_waveform_widget.waveform.config.axis.x_label == "New X Label"
+ assert multi_waveform_widget.waveform.config.axis.y_label == "New Y Label"
+ assert multi_waveform_widget.waveform.config.axis.x_scale == "log"
+ assert multi_waveform_widget.waveform.config.axis.y_scale == "linear"
+ assert multi_waveform_widget.waveform.config.axis.x_lim == (5, 15)
+ assert multi_waveform_widget.waveform.config.axis.y_lim == (5, 15)
+
+
+###################################
+# Theme Update Test
+###################################
+
+
+def test_multi_waveform_widget_theme_update(qtbot, multi_waveform_widget):
+ """Test theme update for multi waveform widget."""
+ qapp = QApplication.instance()
+
+ # Set the theme to dark
+ set_theme("dark")
+ palette = get_theme_palette()
+ waveform_color_dark = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
+ bg_color = multi_waveform_widget.fig.backgroundBrush().color()
+ assert bg_color == QColor("black")
+ assert waveform_color_dark == palette.text().color()
+
+ # Set the theme to light
+ set_theme("light")
+ palette = get_theme_palette()
+ waveform_color_light = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
+ bg_color = multi_waveform_widget.fig.backgroundBrush().color()
+ assert bg_color == QColor("white")
+ assert waveform_color_light == palette.text().color()
+
+ assert waveform_color_dark != waveform_color_light
+
+ # Set the theme to auto and simulate OS theme change
+ set_theme("auto")
+ qapp.theme_signal.theme_updated.emit("dark")
+ apply_theme("dark")
+
+ waveform_color = multi_waveform_widget.waveform.plot_item.getAxis("left").pen().color()
+ bg_color = multi_waveform_widget.fig.backgroundBrush().color()
+ assert bg_color == QColor("black")
+ assert waveform_color == waveform_color_dark