diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 56b0f853..00db1dfe 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -974,7 +974,8 @@ class Curve(RPCBase): - symbol_color: str - symbol_size: int - pen_width: int - - pen_style: Literal["solid", "dash", "dot", "dashdot"] + - pen_style: Literal["solid", "dash", "dot", "dashdot", "none"] + Use "none" to display markers without connecting lines. """ @rpc_call @@ -1046,12 +1047,15 @@ class Curve(RPCBase): """ @rpc_call - def set_pen_style(self, pen_style: "Literal['solid', 'dash', 'dot', 'dashdot']"): + def set_pen_style( + self, pen_style: "Literal['solid', 'dash', 'dot', 'dashdot', 'none']" + ) -> "None": """ Change the pen style of the curve. Args: - pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen. + pen_style(Literal["solid", "dash", "dot", "dashdot", "none"]): Style of the pen. + Use "none" to display markers without connecting lines. """ @rpc_call diff --git a/bec_widgets/widgets/plots/waveform/curve.py b/bec_widgets/widgets/plots/waveform/curve.py index 6644b074..497358d4 100644 --- a/bec_widgets/widgets/plots/waveform/curve.py +++ b/bec_widgets/widgets/plots/waveform/curve.py @@ -41,8 +41,8 @@ class CurveConfig(ConnectionConfig): ) symbol_size: int | None = Field(7, description="The size of the symbol of the curve.") pen_width: int | None = Field(4, description="The width of the pen of the curve.") - pen_style: Literal["solid", "dash", "dot", "dashdot"] | None = Field( - "solid", description="The style of the pen of the curve." + pen_style: Literal["solid", "dash", "dot", "dashdot", "none"] | None = Field( + "solid", description="The style of the curve's connecting line; 'none' disables the line." ) source: Literal["device", "dap", "custom", "history"] = Field( "custom", description="The source of the curve." @@ -134,6 +134,7 @@ class Curve(BECConnector, pg.PlotDataItem): "dash": QtCore.Qt.DashLine, "dot": QtCore.Qt.DotLine, "dashdot": QtCore.Qt.DashDotLine, + "none": QtCore.Qt.NoPen, } pen_style = pen_style_map.get(self.config.pen_style, QtCore.Qt.SolidLine) @@ -238,7 +239,8 @@ class Curve(BECConnector, pg.PlotDataItem): - symbol_color: str - symbol_size: int - pen_width: int - - pen_style: Literal["solid", "dash", "dot", "dashdot"] + - pen_style: Literal["solid", "dash", "dot", "dashdot", "none"] + Use "none" to display markers without connecting lines. """ # Mapping of keywords to setter methods @@ -310,12 +312,13 @@ class Curve(BECConnector, pg.PlotDataItem): self.config.pen_width = pen_width self.apply_config() - def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot"]): + def set_pen_style(self, pen_style: Literal["solid", "dash", "dot", "dashdot", "none"]) -> None: """ Change the pen style of the curve. Args: - pen_style(Literal["solid", "dash", "dot", "dashdot"]): Style of the pen. + pen_style(Literal["solid", "dash", "dot", "dashdot", "none"]): Style of the pen. + Use "none" to display markers without connecting lines. """ self.config.pen_style = pen_style self.apply_config() diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py index af020e51..011f8085 100644 --- a/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py +++ b/bec_widgets/widgets/plots/waveform/settings/curve_settings/curve_tree.py @@ -282,8 +282,10 @@ class CurveRow(QTreeWidgetItem): # Style in col 5 self.style_combo = QComboBox() - self.style_combo.addItems(["solid", "dash", "dot", "dashdot"]) - idx = self.style_combo.findText(self.config.pen_style) + for style in ("solid", "dash", "dot", "dashdot"): + self.style_combo.addItem(style, style) + self.style_combo.addItem("No line", "none") + idx = self.style_combo.findData(self.config.pen_style) if idx >= 0: self.style_combo.setCurrentIndex(idx) self.tree.setItemWidget(self, 5, self.style_combo) @@ -495,7 +497,7 @@ class CurveRow(QTreeWidgetItem): # Common style fields self.config.color = self.color_button.color self.config.symbol_color = self.color_button.color - self.config.pen_style = self.style_combo.currentText() + self.config.pen_style = self.style_combo.currentData() self.config.pen_width = self.width_spin.value() self.config.symbol_size = self.symbol_spin.value() diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index 9e8f986c..2c6fa30a 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -1938,7 +1938,7 @@ class Waveform(PlotBase): Curve items, by deactivating the symbol and activating downsampling auto, method='mean', if the data length exceeds N points. If the data length is less than N points, the symbol will be activated and downsampling will be deactivated. Maximum points will be - 5x the limit. + 5x the limit. Curves without a connecting line retain their markers at any length. Args: curve(Curve): The curve to adjust. @@ -1949,15 +1949,19 @@ class Waveform(PlotBase): if limit <= 1: logger.warning("Limit must be greater than 1.") return + if curve.config.pen_style == "none" and curve.config.symbol is None: + curve.set_symbol("o") + curve.apply_config() if data_length > limit: - if curve.config.symbol is not None: + if curve.config.pen_style != "none" and curve.config.symbol is not None: curve.set_symbol(None) if curve.config.pen_width > 3: curve.set_pen_width(3) curve.setDownsampling(ds=None, auto=True, method=method) curve.setClipToView(True) elif data_length <= limit: - curve.set_symbol("o") + if curve.config.pen_style != "none": + curve.set_symbol("o") curve.set_pen_width(4) curve.setDownsampling(ds=1, auto=None, method=method) curve.setClipToView(True) diff --git a/tests/unit_tests/test_curve_settings.py b/tests/unit_tests/test_curve_settings.py index 6e330a38..f8d9fd05 100644 --- a/tests/unit_tests/test_curve_settings.py +++ b/tests/unit_tests/test_curve_settings.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from bec_lib.scan_history import ScanHistory +from qtpy.QtCore import Qt from qtpy.QtWidgets import QComboBox, QVBoxLayout from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting @@ -84,6 +85,40 @@ def test_curve_setting_accept_changes(curve_setting_fixture, qtbot): send_spy.assert_called_once() +@pytest.mark.parametrize( + "style, label, expected_pen", + [ + ("none", "No line", Qt.NoPen), + ("solid", "solid", Qt.SolidLine), + ("dash", "dash", Qt.DashLine), + ("dot", "dot", Qt.DotLine), + ("dashdot", "dashdot", Qt.DashDotLine), + ], +) +def test_curve_setting_style_round_trip(curve_setting_fixture, style, label, expected_pen): + """Applying and restoring curve styles preserves the selection and rendered pen.""" + settings, wf = curve_setting_fixture + wf.plot("bpm4i") + settings.refresh() + row = settings.curve_manager.all_items[0] + row.style_combo.setCurrentText(label) + settings.accept_changes() + + serialized = wf.curve_json + assert json.loads(serialized)[0]["pen_style"] == style + wf.curve_json = serialized + curve = wf.curves[0] + curve.setData([1, 2, 3], [4, 5, 6]) + assert curve.curve.opts["pen"].style() == expected_pen + assert curve.scatter.isVisible() + assert curve.scatter.opts["symbol"] == "o" + + settings.refresh() + restored_row = settings.curve_manager.all_items[0] + assert restored_row.style_combo.currentText() == label + assert restored_row.style_combo.currentData() == style + + def test_curve_setting_switch_device_mode(curve_setting_fixture, qtbot): """ If user chooses device mode from the combo, the device_x line edit should be enabled diff --git a/tests/unit_tests/test_waveform.py b/tests/unit_tests/test_waveform.py index 4f08d98c..a1e69a51 100644 --- a/tests/unit_tests/test_waveform.py +++ b/tests/unit_tests/test_waveform.py @@ -11,7 +11,7 @@ import pytest from bec_lib import messages from bec_lib.scan_items import ScanItem from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem -from qtpy.QtCore import QTimer +from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import QApplication, QCheckBox, QDialog, QDialogButtonBox, QDoubleSpinBox from bec_widgets.widgets.plots.plot_base import UIMode @@ -1239,6 +1239,79 @@ def test_curve_set_appearance_methods(qtbot, mocked_client): assert c.config.pen_style == "dashdot" +def test_curve_no_line_style_preserves_markers(qtbot, mocked_client): + """Disabling the connecting line preserves markers across appearance and data updates.""" + wf = create_widget(qtbot, Waveform, client=mocked_client) + curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], symbol="d") + curve.set_pen_style("none") + assert curve.opts["pen"].style() == Qt.NoPen + + curve.set_color("#123456") + curve.set_symbol_color("#654321") + curve.set_symbol_size(12) + curve.set_pen_width(5) + curve.set_data([2, 3, 4], [5, 6, 7]) + + assert curve.config.pen_style == "none" + assert curve.curve.opts["pen"].style() == Qt.NoPen + assert curve.opts["pen"].color().name() == "#123456" + assert curve.scatter.isVisible() + assert curve.scatter.opts["symbol"] == "d" + assert curve.scatter.opts["size"] == 12 + assert curve.scatter.opts["brush"].color().name() == "#654321" + np.testing.assert_array_equal(curve.scatter.getData(), [[2, 3, 4], [5, 6, 7]]) + + +@pytest.mark.parametrize("data_length", [500, 1500]) +@pytest.mark.parametrize("symbol", [None, "d"]) +def test_curve_no_line_style_keeps_async_markers(qtbot, mocked_client, data_length, symbol): + """Async data retain markers, including when automatic styling previously hid them.""" + wf = create_widget(qtbot, Waveform, client=mocked_client) + curve = wf.plot( + x=[1, 2, 3], + y=[4, 5, 6], + pen_style="none", + pen_width=3, + symbol=symbol, + symbol_color="#654321", + symbol_size=12, + ) + + wf._auto_adjust_async_curve_settings(curve, data_length) + curve.setData(np.arange(data_length), np.arange(data_length)) + + assert curve.curve.opts["pen"].style() == Qt.NoPen + assert curve.scatter.isVisible() + assert curve.config.symbol == (symbol or "o") + assert curve.scatter.opts["symbol"] == (symbol or "o") + assert curve.scatter.opts["size"] == 12 + assert curve.scatter.opts["brush"].color().name() == "#654321" + + +def test_curve_no_line_style_restores_existing_styles(qtbot, mocked_client): + """The no-line style is reversible, and legacy null styles still draw solid lines.""" + wf = create_widget(qtbot, Waveform, client=mocked_client) + curve = wf.plot(x=[1, 2, 3], y=[4, 5, 6], pen_style="none") + assert curve.curve.opts["pen"].style() == Qt.NoPen + + for style, expected in ( + ("solid", Qt.SolidLine), + ("dash", Qt.DashLine), + ("dot", Qt.DotLine), + ("dashdot", Qt.DashDotLine), + ): + curve.set_pen_style(style) + assert curve.curve.opts["pen"].style() == expected + assert curve.curve.isVisible() + curve.set(pen_style="none") + assert curve.curve.opts["pen"].style() == Qt.NoPen + + config = curve.config.model_dump() + config["pen_style"] = None + curve.apply_config(config) + assert curve.curve.opts["pen"].style() == Qt.SolidLine + + def test_curve_set_custom_data(qtbot, mocked_client): """ Test that custom curves allow setting new data via set_data.