diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py
index 7040dc71..a022454f 100644
--- a/bec_widgets/cli/client.py
+++ b/bec_widgets/cli/client.py
@@ -32,6 +32,7 @@ class Widgets(str, enum.Enum):
LogPanel = "LogPanel"
Minesweeper = "Minesweeper"
MotorMap = "MotorMap"
+ MultiWaveform = "MultiWaveform"
PositionIndicator = "PositionIndicator"
PositionerBox = "PositionerBox"
PositionerBox2D = "PositionerBox2D"
@@ -3662,6 +3663,415 @@ class MotorMap(RPCBase):
"""
+class MultiWaveform(RPCBase):
+ @property
+ @rpc_call
+ def enable_toolbar(self) -> "bool":
+ """
+ Show Toolbar.
+ """
+
+ @enable_toolbar.setter
+ @rpc_call
+ def enable_toolbar(self) -> "bool":
+ """
+ Show Toolbar.
+ """
+
+ @property
+ @rpc_call
+ def enable_side_panel(self) -> "bool":
+ """
+ Show Side Panel
+ """
+
+ @enable_side_panel.setter
+ @rpc_call
+ def enable_side_panel(self) -> "bool":
+ """
+ Show Side Panel
+ """
+
+ @property
+ @rpc_call
+ def enable_fps_monitor(self) -> "bool":
+ """
+ Enable the FPS monitor.
+ """
+
+ @enable_fps_monitor.setter
+ @rpc_call
+ def enable_fps_monitor(self) -> "bool":
+ """
+ Enable the FPS monitor.
+ """
+
+ @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
+ """
+
+ @property
+ @rpc_call
+ def title(self) -> "str":
+ """
+ Set title of the plot.
+ """
+
+ @title.setter
+ @rpc_call
+ def title(self) -> "str":
+ """
+ Set title of the plot.
+ """
+
+ @property
+ @rpc_call
+ def x_label(self) -> "str":
+ """
+ The set label for the x-axis.
+ """
+
+ @x_label.setter
+ @rpc_call
+ def x_label(self) -> "str":
+ """
+ The set label for the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def y_label(self) -> "str":
+ """
+ The set label for the y-axis.
+ """
+
+ @y_label.setter
+ @rpc_call
+ def y_label(self) -> "str":
+ """
+ The set label for the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def x_limits(self) -> "QPointF":
+ """
+ Get the x limits of the plot.
+ """
+
+ @x_limits.setter
+ @rpc_call
+ def x_limits(self) -> "QPointF":
+ """
+ Get the x limits of the plot.
+ """
+
+ @property
+ @rpc_call
+ def y_limits(self) -> "QPointF":
+ """
+ Get the y limits of the plot.
+ """
+
+ @y_limits.setter
+ @rpc_call
+ def y_limits(self) -> "QPointF":
+ """
+ Get the y limits of the plot.
+ """
+
+ @property
+ @rpc_call
+ def x_grid(self) -> "bool":
+ """
+ Show grid on the x-axis.
+ """
+
+ @x_grid.setter
+ @rpc_call
+ def x_grid(self) -> "bool":
+ """
+ Show grid on the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def y_grid(self) -> "bool":
+ """
+ Show grid on the y-axis.
+ """
+
+ @y_grid.setter
+ @rpc_call
+ def y_grid(self) -> "bool":
+ """
+ Show grid on the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def inner_axes(self) -> "bool":
+ """
+ Show inner axes of the plot widget.
+ """
+
+ @inner_axes.setter
+ @rpc_call
+ def inner_axes(self) -> "bool":
+ """
+ Show inner axes of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def outer_axes(self) -> "bool":
+ """
+ Show the outer axes of the plot widget.
+ """
+
+ @outer_axes.setter
+ @rpc_call
+ def outer_axes(self) -> "bool":
+ """
+ Show the outer axes of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def lock_aspect_ratio(self) -> "bool":
+ """
+ Lock aspect ratio of the plot widget.
+ """
+
+ @lock_aspect_ratio.setter
+ @rpc_call
+ def lock_aspect_ratio(self) -> "bool":
+ """
+ Lock aspect ratio of the plot widget.
+ """
+
+ @property
+ @rpc_call
+ def auto_range_x(self) -> "bool":
+ """
+ Set auto range for the x-axis.
+ """
+
+ @auto_range_x.setter
+ @rpc_call
+ def auto_range_x(self) -> "bool":
+ """
+ Set auto range for the x-axis.
+ """
+
+ @property
+ @rpc_call
+ def auto_range_y(self) -> "bool":
+ """
+ Set auto range for the y-axis.
+ """
+
+ @auto_range_y.setter
+ @rpc_call
+ def auto_range_y(self) -> "bool":
+ """
+ Set auto range for the y-axis.
+ """
+
+ @property
+ @rpc_call
+ def x_log(self) -> "bool":
+ """
+ Set X-axis to log scale if True, linear if False.
+ """
+
+ @x_log.setter
+ @rpc_call
+ def x_log(self) -> "bool":
+ """
+ Set X-axis to log scale if True, linear if False.
+ """
+
+ @property
+ @rpc_call
+ def y_log(self) -> "bool":
+ """
+ Set Y-axis to log scale if True, linear if False.
+ """
+
+ @y_log.setter
+ @rpc_call
+ def y_log(self) -> "bool":
+ """
+ Set Y-axis to log scale if True, linear if False.
+ """
+
+ @property
+ @rpc_call
+ def legend_label_size(self) -> "int":
+ """
+ The font size of the legend font.
+ """
+
+ @legend_label_size.setter
+ @rpc_call
+ def legend_label_size(self) -> "int":
+ """
+ The font size of the legend font.
+ """
+
+ @property
+ @rpc_call
+ def highlighted_index(self):
+ """
+ None
+ """
+
+ @highlighted_index.setter
+ @rpc_call
+ def highlighted_index(self):
+ """
+ None
+ """
+
+ @property
+ @rpc_call
+ def highlight_last_curve(self) -> "bool":
+ """
+ Get the highlight_last_curve property.
+ Returns:
+ bool: The highlight_last_curve property.
+ """
+
+ @highlight_last_curve.setter
+ @rpc_call
+ def highlight_last_curve(self) -> "bool":
+ """
+ Get the highlight_last_curve property.
+ Returns:
+ bool: The highlight_last_curve property.
+ """
+
+ @property
+ @rpc_call
+ def color_palette(self) -> "str":
+ """
+ The color palette of the figure widget.
+ """
+
+ @color_palette.setter
+ @rpc_call
+ def color_palette(self) -> "str":
+ """
+ The color palette of the figure widget.
+ """
+
+ @property
+ @rpc_call
+ def opacity(self) -> "int":
+ """
+ The opacity of the figure widget.
+ """
+
+ @opacity.setter
+ @rpc_call
+ def opacity(self) -> "int":
+ """
+ The opacity of the figure widget.
+ """
+
+ @property
+ @rpc_call
+ def flush_buffer(self) -> "bool":
+ """
+ The flush_buffer property.
+ """
+
+ @flush_buffer.setter
+ @rpc_call
+ def flush_buffer(self) -> "bool":
+ """
+ The flush_buffer property.
+ """
+
+ @property
+ @rpc_call
+ def max_trace(self) -> "int":
+ """
+ The maximum number of traces to display on the plot.
+ """
+
+ @max_trace.setter
+ @rpc_call
+ def max_trace(self) -> "int":
+ """
+ The maximum number of traces to display on the plot.
+ """
+
+ @property
+ @rpc_call
+ def monitor(self) -> "str":
+ """
+ The monitor of the figure widget.
+ """
+
+ @monitor.setter
+ @rpc_call
+ def monitor(self) -> "str":
+ """
+ The monitor of the figure widget.
+ """
+
+ @rpc_call
+ def set_curve_limit(self, max_trace: "int", flush_buffer: "bool"):
+ """
+ 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 plot(self, monitor: "str", color_palette: "str | None" = "magma"):
+ """
+ Create a plot for the given monitor.
+ Args:
+ monitor (str): The monitor to set.
+ color_palette (str|None): The color palette to use for the plot.
+ """
+
+ @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 clear_curves(self):
+ """
+ Remove all curves from the plot, excluding crosshair items.
+ """
+
+
class PositionIndicator(RPCBase):
@rpc_call
def set_value(self, position: float):
diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
index d4837d0c..b464e313 100644
--- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py
+++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py
@@ -22,6 +22,7 @@ from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutM
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
from bec_widgets.widgets.plots_next_gen.image.image import Image
from bec_widgets.widgets.plots_next_gen.motor_map.motor_map import MotorMap
+from bec_widgets.widgets.plots_next_gen.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
from bec_widgets.widgets.plots_next_gen.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots_next_gen.waveform.waveform import Waveform
@@ -69,6 +70,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"wf": self.wf,
"scatter": self.scatter,
"scatter_mi": self.scatter,
+ "mwf": self.mwf,
}
)
@@ -152,6 +154,13 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
tab_widget.addTab(eighth_tab, "Motor Map")
tab_widget.setCurrentIndex(7)
+ ninth_tab = QWidget()
+ ninth_tab_layout = QVBoxLayout(ninth_tab)
+ self.mwf = MultiWaveform()
+ ninth_tab_layout.addWidget(self.mwf)
+ tab_widget.addTab(ninth_tab, "MultiWaveform")
+ tab_widget.setCurrentIndex(8)
+
# add stuff to the new Waveform widget
self._init_waveform()
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/__init__.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.py
new file mode 100644
index 00000000..bd07e396
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.py
@@ -0,0 +1,501 @@
+from __future__ import annotations
+
+from collections import deque
+
+import pyqtgraph as pg
+from bec_lib.endpoints import MessageEndpoints
+from bec_lib.logger import bec_logger
+from pydantic import Field, ValidationError, field_validator
+from qtpy.QtCore import Signal
+from qtpy.QtWidgets import QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeProperty, SafeSlot
+from bec_widgets.qt_utils.side_panel import SidePanel
+from bec_widgets.utils import Colors, ConnectionConfig
+from bec_widgets.widgets.plots_next_gen.multi_waveform.settings.control_panel import (
+ MultiWaveformControlPanel,
+)
+from bec_widgets.widgets.plots_next_gen.multi_waveform.toolbar_bundles.monitor_selection import (
+ MultiWaveformSelectionToolbarBundle,
+)
+from bec_widgets.widgets.plots_next_gen.plot_base import PlotBase
+
+logger = bec_logger.logger
+
+
+class MultiWaveformConfig(ConnectionConfig):
+ color_palette: str | None = Field(
+ "magma", description="The color palette of the figure widget.", validate_default=True
+ )
+ curve_limit: int | None = Field(
+ 200, description="The maximum number of curves to display on the plot."
+ )
+ flush_buffer: bool | None = Field(
+ False, description="Flush the buffer of the plot widget when the curve limit is reached."
+ )
+ monitor: str | None = Field(None, description="The monitor to set for the plot widget.")
+ curve_width: int | None = Field(1, description="The width of the curve on the plot.")
+ opacity: int | None = Field(50, description="The opacity of the curve on the plot.")
+ highlight_last_curve: bool | None = 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 MultiWaveform(PlotBase):
+ PLUGIN = True
+ RPC = True
+ ICON_NAME = "ssid_chart"
+ USER_ACCESS = [
+ # General PlotBase Settings
+ "enable_toolbar",
+ "enable_toolbar.setter",
+ "enable_side_panel",
+ "enable_side_panel.setter",
+ "enable_fps_monitor",
+ "enable_fps_monitor.setter",
+ "set",
+ "title",
+ "title.setter",
+ "x_label",
+ "x_label.setter",
+ "y_label",
+ "y_label.setter",
+ "x_limits",
+ "x_limits.setter",
+ "y_limits",
+ "y_limits.setter",
+ "x_grid",
+ "x_grid.setter",
+ "y_grid",
+ "y_grid.setter",
+ "inner_axes",
+ "inner_axes.setter",
+ "outer_axes",
+ "outer_axes.setter",
+ "lock_aspect_ratio",
+ "lock_aspect_ratio.setter",
+ "auto_range_x",
+ "auto_range_x.setter",
+ "auto_range_y",
+ "auto_range_y.setter",
+ "x_log",
+ "x_log.setter",
+ "y_log",
+ "y_log.setter",
+ "legend_label_size",
+ "legend_label_size.setter",
+ # MultiWaveform Specific RPC Access
+ "highlighted_index",
+ "highlighted_index.setter",
+ "highlight_last_curve",
+ "highlight_last_curve.setter",
+ "color_palette",
+ "color_palette.setter",
+ "opacity",
+ "opacity.setter",
+ "flush_buffer",
+ "flush_buffer.setter",
+ "max_trace",
+ "max_trace.setter",
+ "monitor",
+ "monitor.setter",
+ "set_curve_limit",
+ "plot",
+ "set_curve_highlight",
+ "clear_curves",
+ ]
+
+ monitor_signal_updated = Signal()
+ highlighted_curve_index_changed = Signal(int)
+
+ def __init__(
+ self,
+ parent: QWidget | None = None,
+ config: MultiWaveformConfig | None = None,
+ client=None,
+ gui_id: str | None = None,
+ popups: bool = True,
+ **kwargs,
+ ):
+ if config is None:
+ config = MultiWaveformConfig(widget_class=self.__class__.__name__)
+ super().__init__(
+ parent=parent, config=config, client=client, gui_id=gui_id, popups=popups, **kwargs
+ )
+
+ # For PropertyManager identification
+ self.setObjectName("MultiWaveform")
+
+ # Scan Data
+ self.old_scan_id = None
+ self.scan_id = None
+ self.connected = False
+ self._current_highlight_index = 0
+ self._curves = deque()
+ self.visible_curves = []
+ self.number_of_visible_curves = 0
+
+ self._init_control_panel()
+
+ ################################################################################
+ # Widget Specific GUI interactions
+ ################################################################################
+ def _init_toolbar(self):
+ self.monitor_selection_bundle = MultiWaveformSelectionToolbarBundle(
+ bundle_id="motor_selection", target_widget=self
+ )
+ self.toolbar.add_bundle(self.monitor_selection_bundle, target_widget=self)
+ super()._init_toolbar()
+ self.toolbar.widgets["reset_legend"].action.setVisible(False)
+
+ def _init_control_panel(self):
+ self.control_panel = SidePanel(self, orientation="top", panel_max_width=90)
+ self.layout_manager.add_widget_relative(
+ self.control_panel, self.round_plot_widget, "bottom"
+ )
+ self.controls = MultiWaveformControlPanel(target_widget=self)
+ self.control_panel.add_menu(
+ action_id="control",
+ icon_name="tune",
+ tooltip="Show Control panel",
+ widget=self.controls,
+ title=None,
+ )
+ self.control_panel.toolbar.widgets["control"].action.trigger()
+
+ ################################################################################
+ # Widget Specific Properties
+ ################################################################################
+
+ @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
+
+ @SafeProperty(int, designable=False)
+ def highlighted_index(self):
+ return self._current_highlight_index
+
+ @highlighted_index.setter
+ def highlighted_index(self, value: int):
+ self._current_highlight_index = value
+ self.property_changed.emit("highlighted_index", value)
+ self.set_curve_highlight(value)
+
+ @SafeProperty(bool)
+ 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
+ self.property_changed.emit("highlight_last_curve", value)
+ self.set_curve_highlight(-1)
+
+ @SafeProperty(str)
+ def color_palette(self) -> str:
+ """
+ The color palette of the figure widget.
+ """
+ return self.config.color_palette
+
+ @color_palette.setter
+ def color_palette(self, value: str):
+ """
+ Set the color palette of the figure widget.
+
+ Args:
+ value(str): The color palette to set.
+ """
+ try:
+ self.config.color_palette = value
+ except ValidationError:
+ return
+ self.set_curve_highlight(self._current_highlight_index)
+ self._sync_monitor_selection_toolbar()
+
+ @SafeProperty(int)
+ def opacity(self) -> int:
+ """
+ The opacity of the figure widget.
+ """
+ return self.config.opacity
+
+ @opacity.setter
+ def opacity(self, value: int):
+ """
+ Set the opacity of the figure widget.
+
+ Args:
+ value(int): The opacity to set.
+ """
+ self.config.opacity = max(0, min(100, value))
+ self.property_changed.emit("opacity", value)
+ self.set_curve_highlight(self._current_highlight_index)
+
+ @SafeProperty(bool)
+ def flush_buffer(self) -> bool:
+ """
+ The flush_buffer property.
+ """
+ return self.config.flush_buffer
+
+ @flush_buffer.setter
+ def flush_buffer(self, value: bool):
+ self.config.flush_buffer = value
+ self.property_changed.emit("flush_buffer", value)
+ self.set_curve_limit(
+ max_trace=self.config.curve_limit, flush_buffer=self.config.flush_buffer
+ )
+
+ @SafeProperty(int)
+ def max_trace(self) -> int:
+ """
+ The maximum number of traces to display on the plot.
+ """
+ return self.config.curve_limit
+
+ @max_trace.setter
+ def max_trace(self, value: int):
+ """
+ Set the maximum number of traces to display on the plot.
+
+ Args:
+ value(int): The maximum number of traces to display.
+ """
+ self.config.curve_limit = value
+ self.property_changed.emit("max_trace", value)
+ self.set_curve_limit(
+ max_trace=self.config.curve_limit, flush_buffer=self.config.flush_buffer
+ )
+
+ @SafeProperty(str)
+ def monitor(self) -> str:
+ """
+ The monitor of the figure widget.
+ """
+ return self.config.monitor
+
+ @monitor.setter
+ def monitor(self, value: str):
+ """
+ Set the monitor of the figure widget.
+
+ Args:
+ value(str): The monitor to set.
+ """
+ self.plot(value)
+
+ ################################################################################
+ # High Level methods for API
+ ################################################################################
+
+ @SafeSlot(popup_error=True)
+ def plot(self, monitor: str, color_palette: str | None = "magma"):
+ """
+ Create a plot for the given monitor.
+ Args:
+ monitor (str): The monitor to set.
+ color_palette (str|None): The color palette to use for the plot.
+ """
+ self.entry_validator.validate_monitor(monitor)
+ self._disconnect_monitor()
+ self.config.monitor = monitor
+ self._connect_monitor()
+ if color_palette is not None:
+ self.color_palette = color_palette
+ self._sync_monitor_selection_toolbar()
+
+ @SafeSlot(int, bool)
+ def set_curve_limit(self, max_trace: int, flush_buffer: bool):
+ """
+ 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.
+ """
+ if max_trace != self.config.curve_limit:
+ self.config.curve_limit = max_trace
+ if flush_buffer != self.config.flush_buffer:
+ 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()
+ self.monitor_signal_updated.emit()
+
+ ################################################################################
+ # BEC Update Methods
+ ################################################################################
+ @SafeSlot(dict, dict)
+ def on_monitor_1d_update(self, msg: dict, metadata: dict):
+ """
+ Update the plot widget with the monitor data.
+
+ Args:
+ msg(dict): The message data.
+ metadata(dict): The metadata of the message.
+ """
+ data = msg.get("data", None)
+ current_scan_id = metadata.get("scan_id", None)
+
+ if current_scan_id != self.scan_id:
+ self.scan_id = current_scan_id
+ self.clear_curves()
+ self.curves.clear()
+ if self.crosshair:
+ self.crosshair.clear_markers()
+
+ # Always create a new curve and add it
+ curve = pg.PlotDataItem()
+ curve.setData(data)
+ self.plot_item.addItem(curve)
+ self.curves.append(curve)
+
+ # Max Trace and scale colors
+ self.set_curve_limit(self.config.curve_limit, self.config.flush_buffer)
+
+ @SafeSlot(int)
+ def set_curve_highlight(self, index: int):
+ """
+ Set the curve highlight based on visible curves.
+
+ Args:
+ index (int): The index of the curve to highlight among visible curves.
+ """
+ self.plot_item.visible_curves = [curve for curve in self.curves if curve.isVisible()]
+ num_visible_curves = len(self.plot_item.visible_curves)
+ self.number_of_visible_curves = num_visible_curves
+
+ if num_visible_curves == 0:
+ return # No curves to highlight
+
+ if index >= num_visible_curves:
+ index = num_visible_curves - 1
+ elif index < 0:
+ index = num_visible_curves + index
+ self._current_highlight_index = index
+ num_colors = num_visible_curves
+ colors = Colors.evenly_spaced_colors(
+ colormap=self.config.color_palette, num=num_colors, format="HEX"
+ )
+ for i, curve in enumerate(self.plot_item.visible_curves):
+ curve.setPen()
+ if i == self._current_highlight_index:
+ curve.setPen(pg.mkPen(color=colors[i], width=5))
+ curve.setAlpha(alpha=1, auto=False)
+ curve.setZValue(1)
+ else:
+ curve.setPen(pg.mkPen(color=colors[i], width=1))
+ curve.setAlpha(alpha=self.config.opacity / 100, auto=False)
+ curve.setZValue(0)
+
+ self.highlighted_curve_index_changed.emit(self._current_highlight_index)
+
+ def _disconnect_monitor(self):
+ try:
+ previous_monitor = self.config.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)
+ )
+ self.connected = False
+
+ def _connect_monitor(self):
+ """
+ Connect the monitor to the plot widget.
+ """
+
+ 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
+
+ ################################################################################
+ # Utility Methods
+ ################################################################################
+ def scale_colors(self):
+ """
+ Scale the colors of the curves based on the current colormap.
+ """
+ # TODO probably has to be changed to property
+ 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 hook_crosshair(self) -> None:
+ """
+ Specific hookfor crosshair, since it is for multiple curves.
+ """
+ super().hook_crosshair()
+ if self.crosshair:
+ self.highlighted_curve_index_changed.connect(self.crosshair.update_highlighted_curve)
+ if self.curves:
+ self.crosshair.update_highlighted_curve(self._current_highlight_index)
+
+ def clear_curves(self):
+ """
+ Remove all curves from the plot, excluding crosshair items.
+ """
+ items_to_remove = []
+ for item in self.plot_item.items:
+ if not getattr(item, "is_crosshair", False) and isinstance(item, pg.PlotDataItem):
+ items_to_remove.append(item)
+ for item in items_to_remove:
+ self.plot_item.removeItem(item)
+
+ def _sync_monitor_selection_toolbar(self):
+ """
+ Sync the motor map selection toolbar with the current motor map.
+ """
+ if self.monitor_selection_bundle is not None:
+ monitor = self.monitor_selection_bundle.monitor.currentText()
+ color_palette = self.monitor_selection_bundle.colormap_widget.colormap
+
+ if monitor != self.config.monitor:
+ self.monitor_selection_bundle.monitor.blockSignals(True)
+ self.monitor_selection_bundle.monitor.set_device(self.config.monitor)
+ self.monitor_selection_bundle.monitor.check_validity(self.config.monitor)
+ self.monitor_selection_bundle.monitor.blockSignals(False)
+
+ if color_palette != self.config.color_palette:
+ self.monitor_selection_bundle.colormap_widget.blockSignals(True)
+ self.monitor_selection_bundle.colormap_widget.colormap = self.config.color_palette
+ self.monitor_selection_bundle.colormap_widget.blockSignals(False)
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.pyproject b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.pyproject
new file mode 100644
index 00000000..452825ab
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.pyproject
@@ -0,0 +1 @@
+{'files': ['multi_waveform.py']}
\ No newline at end of file
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform_plugin.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform_plugin.py
new file mode 100644
index 00000000..d7164878
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform_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.plots_next_gen.multi_waveform.multi_waveform import MultiWaveform
+
+DOM_XML = """
+
+
+
+
+"""
+
+
+class MultiWaveformPlugin(QDesignerCustomWidgetInterface): # pragma: no cover
+ def __init__(self):
+ super().__init__()
+ self._form_editor = None
+
+ def createWidget(self, parent):
+ t = MultiWaveform(parent)
+ return t
+
+ def domXml(self):
+ return DOM_XML
+
+ def group(self):
+ return "Plot Widgets Next Gen"
+
+ def icon(self):
+ return designer_material_icon(MultiWaveform.ICON_NAME)
+
+ def includeFile(self):
+ return "multi_waveform"
+
+ def initialize(self, form_editor):
+ self._form_editor = form_editor
+
+ def isContainer(self):
+ return False
+
+ def isInitialized(self):
+ return self._form_editor is not None
+
+ def name(self):
+ return "MultiWaveform"
+
+ def toolTip(self):
+ return "MultiWaveform"
+
+ def whatsThis(self):
+ return self.toolTip()
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/register_multi_waveform.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/register_multi_waveform.py
new file mode 100644
index 00000000..42bc3d87
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/register_multi_waveform.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.plots_next_gen.multi_waveform.multi_waveform_plugin import (
+ MultiWaveformPlugin,
+ )
+
+ QPyDesignerCustomWidgetCollection.addCustomWidget(MultiWaveformPlugin())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/__init__.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/control_panel.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/control_panel.py
new file mode 100644
index 00000000..c9db43be
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/control_panel.py
@@ -0,0 +1,145 @@
+import os
+
+from qtpy.QtWidgets import QVBoxLayout, QWidget
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.settings_dialog import SettingWidget
+from bec_widgets.utils import UILoader
+from bec_widgets.utils.widget_io import WidgetIO
+
+
+class MultiWaveformControlPanel(SettingWidget):
+ """
+ A settings widget MultiWaveformControlPanel that allows the user to modify the properties.
+
+ The widget has skip_settings property set to True, which means it should not be saved
+ in the settings file. It is used to mirror the properties of the target widget.
+ """
+
+ def __init__(self, parent=None, target_widget=None, *args, **kwargs):
+ super().__init__(parent=parent, *args, **kwargs)
+
+ self.setProperty("skip_settings", True)
+ self.setObjectName("MultiWaveformControlPanel")
+ current_path = os.path.dirname(__file__)
+
+ form = UILoader().load_ui(os.path.join(current_path, "multi_waveform_controls.ui"), self)
+
+ self.target_widget = target_widget
+
+ self.layout = QVBoxLayout(self)
+ self.layout.setContentsMargins(0, 0, 0, 0)
+ self.ui = form
+
+ self.ui_widget_list = [
+ self.ui.opacity,
+ self.ui.highlighted_index,
+ self.ui.highlight_last_curve,
+ self.ui.flush_buffer,
+ self.ui.max_trace,
+ ]
+
+ if self.target_widget is not None:
+ self.connect_all_signals()
+ self.target_widget.property_changed.connect(self.update_property)
+ self.target_widget.monitor_signal_updated.connect(self.update_controls_limits)
+ self.ui.highlight_last_curve.toggled.connect(self.set_highlight_last_curve)
+
+ self.fetch_all_properties()
+
+ def connect_all_signals(self):
+ for widget in self.ui_widget_list:
+ WidgetIO.connect_widget_change_signal(widget, self.set_property)
+
+ @SafeSlot()
+ def set_property(self, widget: QWidget, value):
+ """
+ Set property of the target widget based on the widget that emitted the signal.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ widget(QWidget): The widget that emitted the signal.
+ value(): The value to set the property to.
+ """
+
+ try: # to avoid crashing when the widget is not found in Designer
+ property_name = widget.objectName()
+ setattr(self.target_widget, property_name, value)
+ except RuntimeError:
+ return
+
+ @SafeSlot()
+ def update_property(self, property_name: str, value):
+ """
+ Update the value of the widget based on the property name and value.
+ The name of the property has to be the same as the objectName of the widget
+ and compatible with WidgetIO.
+
+ Args:
+ property_name(str): The name of the property to update.
+ value: The value to set the property to.
+ """
+ try: # to avoid crashing when the widget is not found in Designer
+ widget_to_set = self.ui.findChild(QWidget, property_name)
+ except RuntimeError:
+ return
+ if widget_to_set is None:
+ return
+
+ WidgetIO.set_value(widget_to_set, value)
+
+ def fetch_all_properties(self):
+ """
+ Fetch all properties from the target widget and update the settings widget.
+ """
+ for widget in self.ui_widget_list:
+ property_name = widget.objectName()
+ value = getattr(self.target_widget, property_name)
+ WidgetIO.set_value(widget, value)
+
+ def accept_changes(self):
+ """
+ Apply all properties from the settings widget to the target widget.
+ """
+ for widget in self.ui_widget_list:
+ property_name = widget.objectName()
+ value = WidgetIO.get_value(widget)
+ setattr(self.target_widget, property_name, value)
+
+ @SafeSlot()
+ def update_controls_limits(self):
+ """
+ Update the limits of the controls.
+ """
+ num_curves = len(self.target_widget.curves)
+ if num_curves == 0:
+ num_curves = 1 # Avoid setting max to 0
+ current_index = num_curves - 1
+ self.ui.highlighted_index.setMinimum(0)
+ self.ui.highlighted_index.setMaximum(self.target_widget.number_of_visible_curves - 1)
+ self.ui.spinbox_index.setMaximum(self.target_widget.number_of_visible_curves - 1)
+ if self.ui.highlight_last_curve.isChecked():
+ self.ui.highlighted_index.setValue(current_index)
+ self.ui.spinbox_index.setValue(current_index)
+
+ @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.target_widget.config.highlight_last_curve = enable
+ if enable:
+ self.ui.highlighted_index.setEnabled(False)
+ self.ui.spinbox_index.setEnabled(False)
+ self.ui.highlight_last_curve.setChecked(True)
+ self.target_widget.set_curve_highlight(-1)
+ else:
+ self.ui.highlighted_index.setEnabled(True)
+ self.ui.spinbox_index.setEnabled(True)
+ self.ui.highlight_last_curve.setChecked(False)
+ index = self.ui.spinbox_index.value()
+ self.target_widget.set_curve_highlight(index)
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/multi_waveform_controls.ui b/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/multi_waveform_controls.ui
new file mode 100644
index 00000000..60b016bf
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/settings/multi_waveform_controls.ui
@@ -0,0 +1,164 @@
+
+
+ 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
+
+
+
+
+
+
+
+
+ opacity
+ valueChanged(int)
+ spinbox_opacity
+ setValue(int)
+
+
+ 211
+ 66
+
+
+ 260
+ 59
+
+
+
+
+ spinbox_opacity
+ valueChanged(int)
+ opacity
+ setValue(int)
+
+
+ 269
+ 62
+
+
+ 182
+ 62
+
+
+
+
+ highlighted_index
+ valueChanged(int)
+ spinbox_index
+ setValue(int)
+
+
+ 191
+ 27
+
+
+ 256
+ 27
+
+
+
+
+ spinbox_index
+ valueChanged(int)
+ highlighted_index
+ setValue(int)
+
+
+ 264
+ 20
+
+
+ 195
+ 24
+
+
+
+
+
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/__init__.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/monitor_selection.py b/bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/monitor_selection.py
new file mode 100644
index 00000000..146c56eb
--- /dev/null
+++ b/bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/monitor_selection.py
@@ -0,0 +1,58 @@
+from bec_lib.device import ReadoutPriority
+from qtpy.QtCore import Qt
+from qtpy.QtWidgets import QStyledItemDelegate
+
+from bec_widgets.qt_utils.error_popups import SafeSlot
+from bec_widgets.qt_utils.toolbar import ToolbarBundle, WidgetAction
+from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter
+from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox
+from bec_widgets.widgets.utility.visual.colormap_widget.colormap_widget import BECColorMapWidget
+
+
+class NoCheckDelegate(QStyledItemDelegate):
+ """To reduce space in combo boxes by removing the checkmark."""
+
+ def initStyleOption(self, option, index):
+ super().initStyleOption(option, index)
+ # Remove any check indicator
+ option.checkState = Qt.Unchecked
+
+
+class MultiWaveformSelectionToolbarBundle(ToolbarBundle):
+ """
+ A bundle of actions for a toolbar that selects motors.
+ """
+
+ def __init__(self, bundle_id="monitor_selection", target_widget=None, **kwargs):
+ super().__init__(bundle_id=bundle_id, actions=[], **kwargs)
+ self.target_widget = target_widget
+
+ # Monitor Selection
+ self.monitor = DeviceComboBox(
+ device_filter=BECDeviceFilter.DEVICE, readout_priority_filter=ReadoutPriority.ASYNC
+ )
+ self.monitor.addItem("", None)
+ self.monitor.setCurrentText("")
+ self.monitor.setToolTip("Select Monitor")
+ self.monitor.setItemDelegate(NoCheckDelegate(self.monitor))
+ self.add_action("monitor", WidgetAction(widget=self.monitor, adjust_size=False))
+
+ # Colormap Selection
+ self.colormap_widget = BECColorMapWidget(cmap="magma")
+ self.add_action("color_map", WidgetAction(widget=self.colormap_widget, adjust_size=False))
+
+ # Connect slots, a device will be connected upon change of any combobox
+ self.monitor.currentTextChanged.connect(lambda: self.connect())
+ self.colormap_widget.colormap_changed_signal.connect(self.change_colormap)
+
+ @SafeSlot()
+ def connect(self):
+ monitor = self.monitor.currentText()
+
+ if monitor != "":
+ if monitor != self.target_widget.config.monitor:
+ self.target_widget.monitor = monitor
+
+ @SafeSlot(str)
+ def change_colormap(self, colormap: str):
+ self.target_widget.color_palette = colormap
diff --git a/tests/unit_tests/test_multi_waveform_next_gen.py b/tests/unit_tests/test_multi_waveform_next_gen.py
new file mode 100644
index 00000000..5b845b0e
--- /dev/null
+++ b/tests/unit_tests/test_multi_waveform_next_gen.py
@@ -0,0 +1,342 @@
+import numpy as np
+
+from bec_widgets.widgets.plots_next_gen.multi_waveform.multi_waveform import MultiWaveform
+from tests.unit_tests.client_mocks import mocked_client
+
+from .conftest import create_widget
+
+##################################################
+# MultiWaveform widget base functionality tests
+##################################################
+
+
+def test_multiwaveform_initialization(qtbot, mocked_client):
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ assert mw.objectName() == "MultiWaveform"
+ # Inherited from PlotBase
+ assert mw.title == ""
+ assert mw.x_label == ""
+ assert mw.y_label == ""
+ # No crosshair or FPS monitor by default
+ assert mw.crosshair is None
+ assert mw.fps_monitor is None
+ # No curves initially
+ assert len(mw.plot_item.curves) == 0
+ # Multiwaveform specific
+ assert mw.monitor is None
+ assert mw.color_palette == "magma"
+ assert mw.max_trace == 200
+ assert mw.flush_buffer is False
+ assert mw.highlight_last_curve is True
+ assert mw.opacity == 50
+ assert mw.scan_id is None
+ assert mw.highlighted_index == 0
+
+
+def test_multiwaveform_set_monitor(qtbot, mocked_client):
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ assert mw.monitor is None
+
+ # Set a monitor
+ mw.plot("waveform1d")
+ assert mw.monitor == "waveform1d"
+ assert mw.config.monitor == "waveform1d"
+ assert mw.connected is True
+
+
+def test_multiwaveform_set_properties(qtbot, mocked_client):
+ """Check that MultiWaveform properties can be set and retrieved correctly."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ # Default checks
+ assert mw.color_palette == "magma"
+ assert mw.max_trace == 200
+ assert mw.flush_buffer is False
+ assert mw.highlight_last_curve is True
+ assert mw.opacity == 50
+
+ # Change properties
+ mw.color_palette = "viridis"
+ mw.max_trace = 10
+ mw.flush_buffer = True
+ mw.highlight_last_curve = False
+ mw.opacity = 75
+
+ # Verify that changes took effect
+ assert mw.color_palette == "viridis"
+ assert mw.max_trace == 10
+ assert mw.flush_buffer is True
+ assert mw.highlight_last_curve is False
+ assert mw.opacity == 75
+
+
+def test_multiwaveform_curve_limit_no_flush(qtbot, mocked_client):
+ """Check that limiting the number of curves without flush simply hides older ones."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.max_trace = 3
+ mw.flush_buffer = False
+
+ # Simulate updates that create multiple curves
+ for i in range(5):
+ msg_data = {"data": np.array([i, i + 0.5, i + 1])}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
+
+ # There should be 5 curves in total, but only the last 3 are visible
+ assert len(mw.curves) == 5
+ visible_curves = [c for c in mw.curves if c.isVisible()]
+ assert len(visible_curves) == 3
+
+
+def test_multiwaveform_curve_limit_flush(qtbot, mocked_client):
+ """Check that limiting the number of curves with flush removes older ones."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.max_trace = 3
+ mw.flush_buffer = True
+
+ # Simulate adding multiple curves
+ for i in range(5):
+ msg_data = {"data": np.array([i, i + 0.5, i + 1])}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
+
+ # Only 3 curves remain after flush
+ assert len(mw.curves) == 3
+ # They should match the last 3 that were inserted
+ x_data, y_data = mw.curves[0].getData()
+ assert np.array_equal(y_data, [2, 2.5, 3])
+ x_data, y_data = mw.curves[1].getData()
+ assert np.array_equal(y_data, [3, 3.5, 4])
+ x_data, y_data = mw.curves[2].getData()
+ assert np.array_equal(y_data, [4, 4.5, 5])
+
+
+def test_multiwaveform_highlight_last_curve(qtbot, mocked_client):
+ """Check highlight_last_curve behavior."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.max_trace = 5
+ mw.flush_buffer = False
+
+ # Simulate adding multiple curves
+ for i in range(3):
+ msg_data = {"data": np.array([i, i + 1, i + 2])}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
+
+ # Initially highlight_last_curve is True, so the last visible curve is highlighted
+ # The highlight index should be -1 in the code's logic
+ assert mw.highlight_last_curve is True
+
+ # Disable highlight_last_curve
+ mw.highlight_last_curve = False
+
+ # Force highlight of the 1st visible curve (index 0 among visible)
+ mw.set_curve_highlight(0)
+ assert mw.highlighted_index == 0
+
+
+def test_multiwaveform_opacity_changes(qtbot, mocked_client):
+ """Check changing opacity affects existing curves."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.plot("waveform1d")
+
+ # Add one curve
+ msg_data = {"data": np.array([10, 20, 30])}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
+ assert len(mw.curves) == 1
+
+ # Default opacity is 50
+ assert mw.opacity == 50
+
+ # Change opacity
+ mw.opacity = 80
+ assert mw.opacity == 80
+
+
+def test_multiwaveform_set_colormap(qtbot, mocked_client):
+ """Check that setting a new colormap updates curve colors."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.plot("waveform1d")
+
+ # Simulate multiple curve updates
+ for i in range(3):
+ msg_data = {"data": np.array([i, i + 1, i + 2])}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_1"})
+
+ # Default color_palette is "magma"
+ assert mw.color_palette == "magma"
+ # Now change to a new colormap
+ mw.color_palette = "viridis"
+ assert mw.color_palette == "viridis"
+
+
+def test_multiwaveform_simulate_updates(qtbot, mocked_client):
+ """Simulate a series of 1D updates to ensure the data is appended and the correct number of curves appear."""
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ mw.plot("waveform1d")
+
+ data_series = [np.random.rand(5), np.random.rand(5), np.random.rand(5)]
+ for idx, arr in enumerate(data_series):
+ msg_data = {"data": arr}
+ mw.on_monitor_1d_update(msg_data, metadata={"scan_id": "scan_99"})
+ # Each update should add a new curve
+ assert len(mw.curves) == idx + 1
+ x_data, y_data = mw.curves[-1].getData()
+ assert np.array_equal(y_data, arr)
+
+ # Check that the scan_id was updated
+ assert mw.scan_id == "scan_99"
+
+
+##################################################
+# MultiWaveform control panel and toolbar
+##################################################
+
+
+def test_control_panel_updates_widget(qtbot, mocked_client):
+ """
+ Interact with the control panel’s UI elements and confirm the widget’s properties are updated.
+ """
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ assert mw.opacity == 50
+ assert mw.flush_buffer is False
+ assert mw.max_trace == 200
+ assert mw.highlight_last_curve is True
+
+ mw.controls.ui.opacity.setValue(80)
+ assert mw.opacity == 80
+
+ mw.controls.ui.flush_buffer.setChecked(True)
+ assert mw.flush_buffer is True
+
+ mw.controls.ui.max_trace.setValue(12)
+ assert mw.max_trace == 12
+
+ mw.controls.ui.highlight_last_curve.setChecked(False)
+ assert mw.highlight_last_curve is False
+
+
+def test_widget_updates_control_panel(qtbot, mocked_client):
+ """
+ Change properties directly on the MultiWaveform and verify the control panel UI reflects those changes.
+ """
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ mw.opacity = 25
+ qtbot.wait(100)
+ assert mw.controls.ui.opacity.value() == 25
+
+ mw.flush_buffer = True
+ qtbot.wait(100)
+ assert mw.controls.ui.flush_buffer.isChecked() is True
+
+ mw.max_trace = 9
+ qtbot.wait(100)
+ assert mw.controls.ui.max_trace.value() == 9
+
+ mw.highlight_last_curve = False
+ qtbot.wait(100)
+ assert mw.controls.ui.highlight_last_curve.isChecked() is False
+
+
+def test_selection_toolbar_updates_widget(qtbot, mocked_client):
+ """
+ Confirm that selecting a monitor and a colormap from the selection toolbar
+ updates the widget properties.
+ """
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+ toolbar = mw.monitor_selection_bundle
+ monitor_combo = toolbar.monitor
+ colormap_widget = toolbar.colormap_widget
+
+ monitor_combo.addItem("waveform1d")
+ monitor_combo.setCurrentText("waveform1d")
+ assert mw.monitor == "waveform1d"
+
+ colormap_widget.colormap = "viridis"
+ assert mw.color_palette == "viridis"
+
+
+def test_control_panel_opacity_slider_spinbox(qtbot, mocked_client):
+ """
+ Verify that when the user moves the opacity slider or spinbox, the widget's
+ opacity property updates, and vice versa. Also confirm they stay in sync.
+ """
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ slider_opacity = mw.controls.ui.opacity
+ spinbox_opacity = mw.controls.ui.spinbox_opacity
+
+ # Default
+ assert mw.opacity == 50
+ assert slider_opacity.value() == 50
+ assert spinbox_opacity.value() == 50
+
+ # Move the slider
+ slider_opacity.setValue(75)
+ assert mw.opacity == 75
+ assert spinbox_opacity.value() == 75
+
+ # Move the spinbox
+ spinbox_opacity.setValue(20)
+ assert mw.opacity == 20
+ assert slider_opacity.value() == 20
+
+ mw.opacity = 95
+ qtbot.wait(100)
+ assert slider_opacity.value() == 95
+ assert spinbox_opacity.value() == 95
+
+
+def test_control_panel_highlight_slider_spinbox(qtbot, mocked_client):
+ """
+ Test that the slider and spinbox for curve highlighting update
+ the widget’s highlighted_index property, and are disabled if
+ highlight_last_curve is True.
+ """
+ mw = create_widget(qtbot, MultiWaveform, client=mocked_client)
+
+ slider_index = mw.controls.ui.highlighted_index
+ spinbox_index = mw.controls.ui.spinbox_index
+ checkbox_highlight_last = mw.controls.ui.highlight_last_curve
+
+ # By default highlight_last_curve is True, so slider/spinbox are disabled:
+ assert checkbox_highlight_last.isChecked() is True
+ assert not slider_index.isEnabled()
+ assert not spinbox_index.isEnabled()
+
+ # Uncheck highlight_last_curve -> slider/spinbox become enabled
+ checkbox_highlight_last.setChecked(False)
+ assert checkbox_highlight_last.isChecked() is False
+ assert slider_index.isEnabled()
+ assert spinbox_index.isEnabled()
+
+ # Simulate a few curves so there's something to highlight
+ data_arrays = [np.array([0, 1, 2]), np.array([3, 4, 5]), np.array([6, 7, 8])]
+ for arr in data_arrays:
+ mw.on_monitor_1d_update({"data": arr}, {"scan_id": "scan_123"})
+
+ # The number_of_visible_curves == 3 now
+ max_index = mw.number_of_visible_curves - 1
+ assert max_index == 2
+
+ # Move the slider to index 1
+ slider_index.setValue(1)
+ assert mw.highlighted_index == 1
+ assert spinbox_index.value() == 1
+
+ # Move the spinbox to index 2
+ spinbox_index.setValue(2)
+ assert mw.highlighted_index == 2
+ assert slider_index.value() == 2
+
+ # Directly set mw.highlighted_index
+ mw.highlighted_index = 0
+ qtbot.wait(100)
+ assert slider_index.value() == 0
+ assert spinbox_index.value() == 0
+
+ # Re-check highlight_last_curve -> slider/spinbox disabled again
+ checkbox_highlight_last.setChecked(True)
+ assert not slider_index.isEnabled()
+ assert not spinbox_index.isEnabled()
+ assert mw.highlighted_index == 2