diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 22fb0b9d..73e4cd2e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -1221,6 +1221,20 @@ class Image(RPCBase): Set auto range for the y-axis. """ + @property + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + + @minimal_crosshair_precision.setter + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + @property @rpc_call def color_map(self) -> "str": @@ -2350,6 +2364,20 @@ class MultiWaveform(RPCBase): The font size of the legend font. """ + @property + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + + @minimal_crosshair_precision.setter + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + @property @rpc_call def highlighted_index(self): @@ -3315,6 +3343,20 @@ class ScatterWaveform(RPCBase): The font size of the legend font. """ + @property + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + + @minimal_crosshair_precision.setter + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + @property @rpc_call def main_curve(self) -> "ScatterCurve": @@ -3789,6 +3831,20 @@ class Waveform(RPCBase): The font size of the legend font. """ + @property + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + + @minimal_crosshair_precision.setter + @rpc_call + def minimal_crosshair_precision(self) -> "int": + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + @property @rpc_call def curves(self) -> "list[Curve]": diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 020b90a5..5e8c2452 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -34,13 +34,21 @@ class Crosshair(QObject): coordinatesChanged2D = Signal(tuple) coordinatesClicked2D = Signal(tuple) - def __init__(self, plot_item: pg.PlotItem, precision: int = 3, parent=None): + def __init__( + self, + plot_item: pg.PlotItem, + precision: int | None = None, + *, + min_precision: int = 2, + parent=None, + ): """ Crosshair for 1D and 2D plots. Args: plot_item (pyqtgraph.PlotItem): The plot item to which the crosshair will be attached. - precision (int, optional): Number of decimal places to round the coordinates to. Defaults to None. + precision (int | None, optional): Fixed number of decimal places to display. If *None*, precision is chosen dynamically from the current view range. + min_precision (int, optional): The lower bound (in decimal places) used when dynamic precision is enabled. Defaults to 2. parent (QObject, optional): Parent object for the QObject. Defaults to None. """ super().__init__(parent) @@ -48,7 +56,9 @@ class Crosshair(QObject): self.is_log_x = None self.is_derivative = None self.plot_item = plot_item - self.precision = precision + self._precision = precision + self._min_precision = max(0, int(min_precision)) # ensure non‑negative + self.v_line = pg.InfiniteLine(angle=90, movable=False) self.v_line.skip_auto_range = True self.h_line = pg.InfiniteLine(angle=0, movable=False) @@ -93,6 +103,56 @@ class Crosshair(QObject): self._connect_to_theme_change() + @property + def precision(self) -> int | None: + """Fixed number of decimals; ``None`` enables dynamic mode.""" + return self._precision + + @precision.setter + def precision(self, value: int | None): + """ + Set the fixed number of decimals to display. + + Args: + value(int | None): The number of decimals to display. If `None`, dynamic precision is used based on the view range. + """ + self._precision = value + + @property + def min_precision(self) -> int: + """Lower bound on decimals when dynamic precision is used.""" + return self._min_precision + + @min_precision.setter + def min_precision(self, value: int): + """ + Set the lower bound on decimals when dynamic precision is used. + + Args: + value(int): The minimum number of decimals to display. Must be non-negative. + """ + self._min_precision = max(0, int(value)) + + def _current_precision(self) -> int: + """ + Get the current precision based on the view range or fixed precision. + """ + if self._precision is not None: + return self._precision + + # Dynamically choose precision from the smaller visible span + view_range = self.plot_item.vb.viewRange() + x_span = abs(view_range[0][1] - view_range[0][0]) + y_span = abs(view_range[1][1] - view_range[1][0]) + + # Ignore zero spans that can appear during initialisation + spans = [s for s in (x_span, y_span) if s > 0] + span = min(spans) if spans else 1.0 + + exponent = np.floor(np.log10(span)) # order of magnitude + decimals = max(0, int(-exponent) + 1) + return max(self._min_precision, decimals) + def _connect_to_theme_change(self): """Connect to the theme change signal.""" qapp = QApplication.instance() @@ -324,6 +384,7 @@ class Crosshair(QObject): # not sure how we got here, but just to be safe... return + precision = self._current_precision() for item in self.items: if isinstance(item, pg.PlotDataItem): name = item.name() or str(id(item)) @@ -334,8 +395,8 @@ class Crosshair(QObject): x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y) coordinate_to_emit = ( name, - round(x_snapped_scaled, self.precision), - round(y_snapped_scaled, self.precision), + round(x_snapped_scaled, precision), + round(y_snapped_scaled, precision), ) self.coordinatesChanged1D.emit(coordinate_to_emit) elif isinstance(item, pg.ImageItem): @@ -380,6 +441,7 @@ class Crosshair(QObject): # not sure how we got here, but just to be safe... return + precision = self._current_precision() for item in self.items: if isinstance(item, pg.PlotDataItem): name = item.name() or str(id(item)) @@ -391,8 +453,8 @@ class Crosshair(QObject): x_snapped_scaled, y_snapped_scaled = self.scale_emitted_coordinates(x, y) coordinate_to_emit = ( name, - round(x_snapped_scaled, self.precision), - round(y_snapped_scaled, self.precision), + round(x_snapped_scaled, precision), + round(y_snapped_scaled, precision), ) self.coordinatesClicked1D.emit(coordinate_to_emit) elif isinstance(item, pg.ImageItem): @@ -443,7 +505,8 @@ class Crosshair(QObject): """ x, y = pos x_scaled, y_scaled = self.scale_emitted_coordinates(x, y) - text = f"({x_scaled:.{self.precision}g}, {y_scaled:.{self.precision}g})" + precision = self._current_precision() + text = f"({x_scaled:.{precision}f}, {y_scaled:.{precision}f})" for item in self.items: if isinstance(item, pg.ImageItem): image = item.image @@ -452,7 +515,7 @@ class Crosshair(QObject): ix = int(np.clip(x, 0, image.shape[0] - 1)) iy = int(np.clip(y, 0, image.shape[1] - 1)) intensity = image[ix, iy] - text += f"\nIntensity: {intensity:.{self.precision}g}" + text += f"\nIntensity: {intensity:.{precision}f}" break # Update coordinate label self.coord_label.setText(text) diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index 950ad1bb..aeb8b01a 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -88,6 +88,8 @@ class Image(PlotBase): "auto_range_x.setter", "auto_range_y", "auto_range_y.setter", + "minimal_crosshair_precision", + "minimal_crosshair_precision.setter", # ImageView Specific Settings "color_map", "color_map.setter", diff --git a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py index d0c6f5cb..75bad154 100644 --- a/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py +++ b/bec_widgets/widgets/plots/multi_waveform/multi_waveform.py @@ -91,6 +91,8 @@ class MultiWaveform(PlotBase): "y_log.setter", "legend_label_size", "legend_label_size.setter", + "minimal_crosshair_precision", + "minimal_crosshair_precision.setter", # MultiWaveform Specific RPC Access "highlighted_index", "highlighted_index.setter", diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index d3e4ff9d..aaad89cd 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -116,6 +116,7 @@ class PlotBase(BECWidget, QWidget): self._user_y_label = "" self._y_label_suffix = "" self._y_axis_units = "" + self._minimal_crosshair_precision = 3 # Plot Indicator Items self.tick_item = BECTickItem(parent=self, plot_item=self.plot_item) @@ -978,7 +979,9 @@ class PlotBase(BECWidget, QWidget): def hook_crosshair(self) -> None: """Hook the crosshair to all plots.""" if self.crosshair is None: - self.crosshair = Crosshair(self.plot_item, precision=3) + self.crosshair = Crosshair( + self.plot_item, min_precision=self._minimal_crosshair_precision + ) self.crosshair.crosshairChanged.connect(self.crosshair_position_changed) self.crosshair.crosshairClicked.connect(self.crosshair_position_clicked) self.crosshair.coordinatesChanged1D.connect(self.crosshair_coordinates_changed) @@ -1006,6 +1009,29 @@ class PlotBase(BECWidget, QWidget): self.unhook_crosshair() + @SafeProperty( + int, doc="Minimum decimal places for crosshair when dynamic precision is enabled." + ) + def minimal_crosshair_precision(self) -> int: + """ + Minimum decimal places for crosshair when dynamic precision is enabled. + """ + return self._minimal_crosshair_precision + + @minimal_crosshair_precision.setter + def minimal_crosshair_precision(self, value: int): + """ + Set the minimum decimal places for crosshair when dynamic precision is enabled. + + Args: + value(int): The minimum decimal places to set. + """ + value_int = max(0, int(value)) + self._minimal_crosshair_precision = value_int + if self.crosshair is not None: + self.crosshair.min_precision = value_int + self.property_changed.emit("minimal_crosshair_precision", value_int) + @SafeSlot() def reset(self) -> None: """Reset the plot widget.""" diff --git a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py index 58a81b77..0bac6832 100644 --- a/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py +++ b/bec_widgets/widgets/plots/scatter_waveform/scatter_waveform.py @@ -82,6 +82,8 @@ class ScatterWaveform(PlotBase): "y_log.setter", "legend_label_size", "legend_label_size.setter", + "minimal_crosshair_precision", + "minimal_crosshair_precision.setter", # Scatter Waveform Specific RPC Access "main_curve", "color_map", diff --git a/bec_widgets/widgets/plots/setting_menus/axis_settings.py b/bec_widgets/widgets/plots/setting_menus/axis_settings.py index d5605ace..68f4ba7b 100644 --- a/bec_widgets/widgets/plots/setting_menus/axis_settings.py +++ b/bec_widgets/widgets/plots/setting_menus/axis_settings.py @@ -60,6 +60,7 @@ class AxisSettings(SettingWidget): self.ui.y_grid, self.ui.inner_axes, self.ui.outer_axes, + self.ui.minimal_crosshair_precision, ]: WidgetIO.connect_widget_change_signal(widget, self.set_property) @@ -121,6 +122,7 @@ class AxisSettings(SettingWidget): self.ui.y_max, self.ui.y_log, self.ui.y_grid, + self.ui.minimal_crosshair_precision, ]: property_name = widget.objectName() value = getattr(self.target_widget, property_name) @@ -144,6 +146,7 @@ class AxisSettings(SettingWidget): self.ui.y_grid, self.ui.outer_axes, self.ui.inner_axes, + self.ui.minimal_crosshair_precision, ]: property_name = widget.objectName() value = WidgetIO.get_value(widget) diff --git a/bec_widgets/widgets/plots/setting_menus/axis_settings_horizontal.ui b/bec_widgets/widgets/plots/setting_menus/axis_settings_horizontal.ui index ea8291c7..91dd15d4 100644 --- a/bec_widgets/widgets/plots/setting_menus/axis_settings_horizontal.ui +++ b/bec_widgets/widgets/plots/setting_menus/axis_settings_horizontal.ui @@ -14,97 +14,6 @@ Form - - - - Inner Axes - - - - - - - - - - Outer Axes - - - - - - - false - - - - - - - X Axis - - - - - - Grid - - - - - - - false - - - - - - - Max - - - - - - - false - - - - - - - Log - - - - - - - Min - - - - - - - Label - - - - - - - - - - - - - - - @@ -179,6 +88,87 @@ + + + + false + + + + + + + Inner Axes + + + + + + + X Axis + + + + + + Grid + + + + + + + false + + + + + + + Max + + + + + + + false + + + + + + + Log + + + + + + + Min + + + + + + + Label + + + + + + + + + + + + + + + @@ -191,8 +181,41 @@ + + + + Precision + + + + + + + Minimal Crosshair Precision + + + Qt::AlignmentFlag::AlignCenter + + + 20 + + + 3 + + + + + + + Outer Axes + + + + + + diff --git a/bec_widgets/widgets/plots/setting_menus/axis_settings_vertical.ui b/bec_widgets/widgets/plots/setting_menus/axis_settings_vertical.ui index 8a4798e1..cf0c2fe2 100644 --- a/bec_widgets/widgets/plots/setting_menus/axis_settings_vertical.ui +++ b/bec_widgets/widgets/plots/setting_menus/axis_settings_vertical.ui @@ -6,15 +6,84 @@ 0 0 - 241 - 526 + 250 + 612 Form - - + + + + + General + + + + + + Outer Axes + + + + + + + false + + + + + + + + + + Inner Axes + + + + + + + Plot Title + + + + + + + + + + Minimal Crosshair Precision + + + Precision + + + + + + + Minimal Crosshair Precision + + + Qt::AlignmentFlag::AlignCenter + + + 20 + + + 3 + + + + + + + X Axis @@ -81,28 +150,7 @@ - - - - - - Plot Title - - - - - - - - - - - - Outer Axes - - - - + Y Axis @@ -169,23 +217,6 @@ - - - - false - - - - - - - Inner Axes - - - - - - diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index ce081672..c78116d8 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -86,6 +86,8 @@ class Waveform(PlotBase): "y_log.setter", "legend_label_size", "legend_label_size.setter", + "minimal_crosshair_precision", + "minimal_crosshair_precision.setter", # Waveform Specific RPC Access "curves", "x_mode", diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 70b7be35..8653c7b0 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -236,7 +236,7 @@ def test_update_coord_label_1D(plot_widget_with_crosshair): # Provide a test position pos = (10, 20) crosshair.update_coord_label(pos) - expected_text = f"({10:.3g}, {20:.3g})" + expected_text = f"({10:.3f}, {20:.3f})" # Verify that the coordinate label shows only the 1D coordinates (no intensity line) assert crosshair.coord_label.toPlainText() == expected_text label_pos = crosshair.coord_label.pos() @@ -260,10 +260,54 @@ def test_update_coord_label_2D(image_widget_with_crosshair): ix = int(np.clip(0.5, 0, known_image.shape[0] - 1)) # 0 iy = int(np.clip(1.2, 0, known_image.shape[1] - 1)) # 1 intensity = known_image[ix, iy] # Expected: 20 - expected_text = f"({0.5:.3g}, {1.2:.3g})\nIntensity: {intensity:.3g}" + expected_text = f"({0.5:.3f}, {1.2:.3f})\nIntensity: {intensity:.3f}" assert crosshair.coord_label.toPlainText() == expected_text label_pos = crosshair.coord_label.pos() assert np.isclose(label_pos.x(), 0.5) assert np.isclose(label_pos.y(), 1.2) assert crosshair.coord_label.isVisible() + + +def test_crosshair_precision_properties(plot_widget_with_crosshair): + """ + Ensure Crosshair.precision and Crosshair.min_precision behave correctly + and that _current_precision() reflects changes immediately. + """ + crosshair, plot_item = plot_widget_with_crosshair + + assert crosshair.precision == 3 + assert crosshair._current_precision() == 3 + + crosshair.precision = None + plot_item.vb.setXRange(0, 1_000, padding=0) + plot_item.vb.setYRange(0, 1_000, padding=0) + assert crosshair._current_precision() == crosshair.min_precision == 2 # default floor + + crosshair.min_precision = 5 + assert crosshair._current_precision() == 5 + + crosshair.precision = 1 + assert crosshair._current_precision() == 1 + + +def test_crosshair_precision_properties_image(image_widget_with_crosshair): + """ + The same precision/min_precision behaviour must apply for crosshairs attached + to ImageItem-based plots. + """ + crosshair, plot_item = image_widget_with_crosshair + + assert crosshair.precision == 3 + assert crosshair._current_precision() == 3 + + crosshair.precision = None + plot_item.vb.setXRange(0, 1_000, padding=0) + plot_item.vb.setYRange(0, 1_000, padding=0) + assert crosshair._current_precision() == crosshair.min_precision == 2 + + crosshair.min_precision = 6 + assert crosshair._current_precision() == 6 + + crosshair.precision = 2 + assert crosshair._current_precision() == 2 diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py index 1e291d15..89dfa000 100644 --- a/tests/unit_tests/test_plot_base_next_gen.py +++ b/tests/unit_tests/test_plot_base_next_gen.py @@ -349,3 +349,39 @@ def test_enable_fps_monitor_property(qtbot, mocked_client): pb.enable_fps_monitor = False assert pb.fps_monitor is None + + +def test_minimal_crosshair_precision_default(qtbot, mocked_client): + """ + By default PlotBase should expose a floor of 3 decimals, with no crosshair yet. + """ + pb = create_widget(qtbot, PlotBase, client=mocked_client) + assert pb.minimal_crosshair_precision == 3 + assert pb.crosshair is None + + +def test_minimal_crosshair_precision_before_hook(qtbot, mocked_client): + """ + If the floor is changed before hook_crosshair(), the new crosshair must pick it up. + """ + pb = create_widget(qtbot, PlotBase, client=mocked_client) + pb.minimal_crosshair_precision = 5 + pb.hook_crosshair() + assert pb.crosshair is not None + assert pb.crosshair.min_precision == 5 + + +def test_minimal_crosshair_precision_after_hook(qtbot, mocked_client): + """ + Changing the floor after the crosshair exists should update it immediately + and emit the property_changed signal. + """ + pb = create_widget(qtbot, PlotBase, client=mocked_client) + pb.hook_crosshair() + assert pb.crosshair is not None + + with qtbot.waitSignal(pb.property_changed, timeout=500) as sig: + pb.minimal_crosshair_precision = 1 + + assert sig.args == ["minimal_crosshair_precision", 1] + assert pb.crosshair.min_precision == 1