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