From 53c8dd807760c6236bcc75f5d93e5a4954dce6c1 Mon Sep 17 00:00:00 2001 From: Tobias Ribizel Date: Fri, 26 Sep 2025 14:48:40 +0200 Subject: [PATCH 1/4] Provide element index with label_formatter label_formatter only provides the name of the plot and the position of the point, but that makes identifying the exact data point unnecessarily complicated, when the index of the data point is available internally. This adds a new enumerated_label_formatter interface that introduces three improvements: 1. Instead of passing an empty string "" for the label when no data point is selected, we pass an Option 2. Instead of returning a String, we return an Option, which allows hiding the label when we don't need it, e.g. when not hovering over a data point. 3. Instead of taking just the position and plot name, we take the position, data point index and plot name. --- egui_plot/src/items/mod.rs | 56 ++++++++++++++++++++------------------ egui_plot/src/lib.rs | 39 ++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 29 deletions(-) diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index 0bf255dc..ab6a6933 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -14,7 +14,7 @@ use egui::{ use emath::Float as _; use rect_elem::{RectElement, highlighted_color}; -use super::{Cursor, LabelFormatter, PlotBounds, PlotTransform}; +use super::{Cursor, NewLabelFormatter, PlotBounds, PlotTransform}; pub use bar::Bar; pub use box_elem::{BoxElem, BoxSpread}; @@ -161,7 +161,7 @@ pub trait PlotItem { shapes: &mut Vec, cursors: &mut Vec, plot: &PlotConfig<'_>, - label_formatter: &LabelFormatter<'_>, + label_formatter: &NewLabelFormatter<'_>, ) { let points = match self.geometry() { PlotGeometry::Points(points) => points, @@ -187,7 +187,7 @@ pub trait PlotItem { rulers_and_tooltip_at_value( plot_area_response, value, - self.name(), + Some((self.name(), elem.index)), plot, cursors, label_formatter, @@ -1441,7 +1441,7 @@ impl PlotItem for BarChart { shapes: &mut Vec, cursors: &mut Vec, plot: &PlotConfig<'_>, - _: &LabelFormatter<'_>, + _: &NewLabelFormatter<'_>, ) { let bar = &self.bars[elem.index]; @@ -1568,7 +1568,7 @@ impl PlotItem for BoxPlot { shapes: &mut Vec, cursors: &mut Vec, plot: &PlotConfig<'_>, - _: &LabelFormatter<'_>, + _: &NewLabelFormatter<'_>, ) { let box_plot = &self.boxes[elem.index]; @@ -1690,14 +1690,15 @@ fn add_rulers_and_text( /// and a label describing the coordinate. /// /// `value` is used to for text displaying X/Y coordinates. +/// `nearest_point` contains the nearest point from a plot with its name and index. #[allow(clippy::too_many_arguments)] pub(super) fn rulers_and_tooltip_at_value( plot_area_response: &egui::Response, value: PlotPoint, - name: &str, + nearest_point: Option<(&str, usize)>, plot: &PlotConfig<'_>, cursors: &mut Vec, - label_formatter: &LabelFormatter<'_>, + label_formatter: &NewLabelFormatter<'_>, ) { if plot.show_x { cursors.push(Cursor::Vertical { x: value.x }); @@ -1707,17 +1708,17 @@ pub(super) fn rulers_and_tooltip_at_value( } let text = if let Some(custom_label) = label_formatter { - custom_label(name, &value) + custom_label(nearest_point, &value) } else { - let prefix = if name.is_empty() { - String::new() - } else { + let prefix = if let Some((name, _)) = nearest_point { format!("{name}\n") + } else { + String::new() }; let scale = plot.transform.dvalue_dpos(); let x_decimals = ((-scale[0].abs().log10()).ceil().at_least(0.0) as usize).clamp(1, 6); let y_decimals = ((-scale[1].abs().log10()).ceil().at_least(0.0) as usize).clamp(1, 6); - if plot.show_x && plot.show_y { + let result = if plot.show_x && plot.show_y { format!( "{}x = {:.*}\ny = {:.*}", prefix, x_decimals, value.x, y_decimals, value.y @@ -1728,25 +1729,28 @@ pub(super) fn rulers_and_tooltip_at_value( format!("{}y = {:.*}", prefix, y_decimals, value.y) } else { unreachable!() - } + }; + Some(result) }; - // We show the tooltip as soon as we're hovering the plot area: - let mut tooltip = egui::Tooltip::always_open( - plot_area_response.ctx.clone(), - plot_area_response.layer_id, - plot_area_response.id, - PopupAnchor::Pointer, - ); + if let Some(text) = text { + // We show the tooltip as soon as we're hovering the plot area: + let mut tooltip = egui::Tooltip::always_open( + plot_area_response.ctx.clone(), + plot_area_response.layer_id, + plot_area_response.id, + PopupAnchor::Pointer, + ); - let tooltip_width = plot_area_response.ctx.style().spacing.tooltip_width; + let tooltip_width = plot_area_response.ctx.style().spacing.tooltip_width; - tooltip.popup = tooltip.popup.width(tooltip_width); + tooltip.popup = tooltip.popup.width(tooltip_width); - tooltip.gap(12.0).show(|ui| { - ui.set_max_width(tooltip_width); - ui.label(text); - }); + tooltip.gap(12.0).show(|ui| { + ui.set_max_width(tooltip_width); + ui.label(text); + }); + } } fn find_closest_rect<'a, T>( diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index c597d257..4cbdab2b 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -45,6 +45,9 @@ use legend::LegendWidget; type LabelFormatterFn<'a> = dyn Fn(&str, &PlotPoint) -> String + 'a; pub type LabelFormatter<'a> = Option>>; +type NewLabelFormatterFn<'a> = dyn Fn(Option<(&str, usize)>, &PlotPoint) -> Option + 'a; +pub type NewLabelFormatter<'a> = Option>>; + type GridSpacerFn<'a> = dyn Fn(GridInput) -> Vec + 'a; type GridSpacer<'a> = Box>; @@ -178,7 +181,7 @@ pub struct Plot<'a> { show_x: bool, show_y: bool, - label_formatter: LabelFormatter<'a>, + label_formatter: NewLabelFormatter<'a>, coordinates_formatter: Option<(Corner, CoordinatesFormatter<'a>)>, x_axes: Vec>, // default x axes y_axes: Vec>, // default y axes @@ -425,6 +428,36 @@ impl<'a> Plot<'a> { pub fn label_formatter( mut self, label_formatter: impl Fn(&str, &PlotPoint) -> String + 'a, + ) -> Self { + let inner_box = Box::new(label_formatter); + self.label_formatter = Some(Box::new(move |option, point| { + Some(inner_box(option.map_or("", |(name, _)| name), point)) + })); + self + } + + /// Provide a function to customize the on-hover label for the x and y axis. + /// This is a generalized version of label_formatter that also provides the point's index, + /// and allows for the tooltip to be hidden conditionally by returning an Option + /// + /// ``` + /// # egui::__run_test_ui(|ui| { + /// use egui_plot::{Line, Plot, PlotPoints}; + /// let sin: PlotPoints = (0..1000).map(|i| { + /// let x = i as f64 * 0.01; + /// [x, x.sin()] + /// }).collect(); + /// let line = Line::new("sin", sin); + /// Plot::new("my_plot").view_aspect(2.0) + /// .enumerated_label_formatter(|nearest_point, value| { + /// nearest_point.map(|(name, _index)| format!("{}: {:.*}%", name, 1, value.y)) + /// }) + /// .show(ui, |plot_ui| plot_ui.line(line)); + /// # }); + /// ``` + pub fn enumerated_label_formatter( + mut self, + label_formatter: impl Fn(Option<(&str, usize)>, &PlotPoint) -> Option + 'a, ) -> Self { self.label_formatter = Some(Box::new(label_formatter)); self @@ -1598,7 +1631,7 @@ struct PreparedPlot<'a> { items: Vec>, show_x: bool, show_y: bool, - label_formatter: LabelFormatter<'a>, + label_formatter: NewLabelFormatter<'a>, coordinates_formatter: Option<(Corner, CoordinatesFormatter<'a>)>, // axis_formatters: [AxisFormatter; 2], transform: PlotTransform, @@ -1858,7 +1891,7 @@ impl PreparedPlot<'_> { items::rulers_and_tooltip_at_value( plot_area_response, value, - "", + None, &plot, &mut cursors, label_formatter, From 86778180a11f977b9ff93b634e22c7968f892421 Mon Sep 17 00:00:00 2001 From: Tobias Ribizel Date: Fri, 26 Sep 2025 15:03:02 +0200 Subject: [PATCH 2/4] Add enumerated_label_formatter to demo --- demo/src/plot_demo.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/demo/src/plot_demo.rs b/demo/src/plot_demo.rs index 70f2421f..15d0913d 100644 --- a/demo/src/plot_demo.rs +++ b/demo/src/plot_demo.rs @@ -636,14 +636,14 @@ impl CustomAxesDemo { } }; - let label_fmt = |_s: &str, val: &PlotPoint| { - format!( + let label_fmt = |_nearest: Option<(&str, usize)>, val: &PlotPoint| { + Some(format!( "Day {d}, {h}:{m:02}\n{p:.2}%", d = day(val.x), h = hour(val.x), m = minute(val.x), p = percent(val.y) - ) + )) }; ui.label("Zoom in on the X-axis to see hours and minutes"); @@ -669,7 +669,7 @@ impl CustomAxesDemo { .custom_x_axes(x_axes) .custom_y_axes(y_axes) .x_grid_spacer(Self::x_grid) - .label_formatter(label_fmt) + .enumerated_label_formatter(label_fmt) .show(ui, |plot_ui| { plot_ui.line(Self::logistic_fn()); }) From f8a3ed736bd98b607771e3df7e73fb67a6d0a86a Mon Sep 17 00:00:00 2001 From: Tobias Ribizel Date: Fri, 26 Sep 2025 15:05:10 +0200 Subject: [PATCH 3/4] Fix clippy warnings --- egui_plot/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 4cbdab2b..1194596d 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -437,7 +437,7 @@ impl<'a> Plot<'a> { } /// Provide a function to customize the on-hover label for the x and y axis. - /// This is a generalized version of label_formatter that also provides the point's index, + /// This is a generalized version of `label_formatter` that also provides the point's index, /// and allows for the tooltip to be hidden conditionally by returning an Option /// /// ``` From b9d844a83bd87d440645e8b5532ef0ceb9f524cb Mon Sep 17 00:00:00 2001 From: Tobias Ribizel Date: Mon, 29 Sep 2025 12:25:54 +0200 Subject: [PATCH 4/4] replace Option by more descriptive HoverPosition enum I'd love to get rid of the lifetime annotations, but my Rust fu isn't good enough for that. --- demo/src/plot_demo.rs | 23 +++++++++++++--------- egui_plot/src/items/mod.rs | 12 +++++++++++- egui_plot/src/lib.rs | 39 ++++++++++++++++++++++++++++++++------ 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/demo/src/plot_demo.rs b/demo/src/plot_demo.rs index 15d0913d..163af259 100644 --- a/demo/src/plot_demo.rs +++ b/demo/src/plot_demo.rs @@ -8,8 +8,8 @@ use egui::{ use egui_plot::{ Arrows, AxisHints, Bar, BarChart, BoxElem, BoxPlot, BoxSpread, CoordinatesFormatter, Corner, - GridInput, GridMark, HLine, Legend, Line, LineStyle, MarkerShape, Plot, PlotImage, PlotPoint, - PlotPoints, PlotResponse, Points, Polygon, Text, VLine, + GridInput, GridMark, HLine, HoverPosition, Legend, Line, LineStyle, MarkerShape, Plot, + PlotImage, PlotPoint, PlotPoints, PlotResponse, Points, Polygon, Text, VLine, }; // ---------------------------------------------------------------------------- @@ -636,14 +636,19 @@ impl CustomAxesDemo { } }; - let label_fmt = |_nearest: Option<(&str, usize)>, val: &PlotPoint| { - Some(format!( + let label_fmt = |position: &HoverPosition<'_>| match position { + HoverPosition::NearDataPoint { + plot_name: _, + position, + index: _, + } => Some(format!( "Day {d}, {h}:{m:02}\n{p:.2}%", - d = day(val.x), - h = hour(val.x), - m = minute(val.x), - p = percent(val.y) - )) + d = day(position.x), + h = hour(position.x), + m = minute(position.x), + p = percent(position.y) + )), + HoverPosition::Elsewhere { position: _ } => None, }; ui.label("Zoom in on the X-axis to see hours and minutes"); diff --git a/egui_plot/src/items/mod.rs b/egui_plot/src/items/mod.rs index ab6a6933..bfaf80d2 100644 --- a/egui_plot/src/items/mod.rs +++ b/egui_plot/src/items/mod.rs @@ -14,6 +14,8 @@ use egui::{ use emath::Float as _; use rect_elem::{RectElement, highlighted_color}; +use crate::HoverPosition; + use super::{Cursor, NewLabelFormatter, PlotBounds, PlotTransform}; pub use bar::Bar; @@ -1708,7 +1710,15 @@ pub(super) fn rulers_and_tooltip_at_value( } let text = if let Some(custom_label) = label_formatter { - custom_label(nearest_point, &value) + let hover_position = match nearest_point { + Some((name, index)) => HoverPosition::NearDataPoint { + plot_name: name, + position: value, + index: index, + }, + None => HoverPosition::Elsewhere { position: value }, + }; + custom_label(&hover_position) } else { let prefix = if let Some((name, _)) = nearest_point { format!("{name}\n") diff --git a/egui_plot/src/lib.rs b/egui_plot/src/lib.rs index 1194596d..209c9ef8 100644 --- a/egui_plot/src/lib.rs +++ b/egui_plot/src/lib.rs @@ -45,7 +45,7 @@ use legend::LegendWidget; type LabelFormatterFn<'a> = dyn Fn(&str, &PlotPoint) -> String + 'a; pub type LabelFormatter<'a> = Option>>; -type NewLabelFormatterFn<'a> = dyn Fn(Option<(&str, usize)>, &PlotPoint) -> Option + 'a; +type NewLabelFormatterFn<'a> = dyn Fn(&HoverPosition<'_>) -> Option + 'a; pub type NewLabelFormatter<'a> = Option>>; type GridSpacerFn<'a> = dyn Fn(GridInput) -> Vec + 'a; @@ -95,6 +95,23 @@ pub enum Cursor { Vertical { x: f64 }, } +/// Indicates the position of the cursor in a plot for hover purposes. +#[derive(Copy, Clone, PartialEq)] +pub enum HoverPosition<'a> { + NearDataPoint { + /// The name of the plot whose data point is nearest to the cursor + plot_name: &'a str, + /// The position of the nearest data point + position: PlotPoint, + /// The index of the nearest data point in its plot + index: usize, + }, + Elsewhere { + /// The position in the plot over which the cursor hovers + position: PlotPoint, + }, +} + /// Contains the cursors drawn for a plot widget in a single frame. #[derive(PartialEq, Clone)] struct PlotFrameCursors { @@ -430,8 +447,15 @@ impl<'a> Plot<'a> { label_formatter: impl Fn(&str, &PlotPoint) -> String + 'a, ) -> Self { let inner_box = Box::new(label_formatter); - self.label_formatter = Some(Box::new(move |option, point| { - Some(inner_box(option.map_or("", |(name, _)| name), point)) + self.label_formatter = Some(Box::new(move |position| { + Some(match position { + HoverPosition::NearDataPoint { + plot_name, + position, + index: _, + } => inner_box(plot_name, &position), + HoverPosition::Elsewhere { position: _ } => "".to_owned(), + }) })); self } @@ -449,15 +473,18 @@ impl<'a> Plot<'a> { /// }).collect(); /// let line = Line::new("sin", sin); /// Plot::new("my_plot").view_aspect(2.0) - /// .enumerated_label_formatter(|nearest_point, value| { - /// nearest_point.map(|(name, _index)| format!("{}: {:.*}%", name, 1, value.y)) + /// .enumerated_label_formatter(|context| { + /// match context { + /// Some((name, index)) => format!("{}: {:.*}%", name, 1, index), + /// Elsewhere { ... } => None, + /// } /// }) /// .show(ui, |plot_ui| plot_ui.line(line)); /// # }); /// ``` pub fn enumerated_label_formatter( mut self, - label_formatter: impl Fn(Option<(&str, usize)>, &PlotPoint) -> Option + 'a, + label_formatter: impl Fn(&HoverPosition<'_>) -> Option + 'a, ) -> Self { self.label_formatter = Some(Box::new(label_formatter)); self