From 77f96160ab348c1a65ceb55986ea4ea75f8be04a Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Mar 2025 14:39:40 +0100 Subject: [PATCH] feat(multi_waveform): multi-waveform widget based on new PlotBase --- bec_widgets/cli/client.py | 410 ++++++++++++++ .../jupyter_console/jupyter_console_window.py | 9 + .../plots_next_gen/multi_waveform/__init__.py | 0 .../multi_waveform/multi_waveform.py | 501 ++++++++++++++++++ .../multi_waveform/multi_waveform.pyproject | 1 + .../multi_waveform/multi_waveform_plugin.py | 54 ++ .../multi_waveform/register_multi_waveform.py | 17 + .../multi_waveform/settings/__init__.py | 0 .../multi_waveform/settings/control_panel.py | 145 +++++ .../settings/multi_waveform_controls.ui | 164 ++++++ .../toolbar_bundles/__init__.py | 0 .../toolbar_bundles/monitor_selection.py | 58 ++ .../test_multi_waveform_next_gen.py | 342 ++++++++++++ 13 files changed, 1701 insertions(+) create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/__init__.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform.pyproject create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/multi_waveform_plugin.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/register_multi_waveform.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/settings/__init__.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/settings/control_panel.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/settings/multi_waveform_controls.ui create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/__init__.py create mode 100644 bec_widgets/widgets/plots_next_gen/multi_waveform/toolbar_bundles/monitor_selection.py create mode 100644 tests/unit_tests/test_multi_waveform_next_gen.py 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