diff --git a/egui/src/widgets/plot/legend.rs b/egui/src/widgets/plot/legend.rs new file mode 100644 index 00000000000..af3754f1d6b --- /dev/null +++ b/egui/src/widgets/plot/legend.rs @@ -0,0 +1,81 @@ +use std::string::String; + +use crate::*; + +pub(crate) struct LegendEntry { + pub text: String, + pub color: Color32, + pub checked: bool, + pub hovered: bool, +} + +impl LegendEntry { + pub fn new(text: String, color: Color32, checked: bool) -> Self { + Self { + text, + color, + checked, + hovered: false, + } + } +} + +impl Widget for &mut LegendEntry { + fn ui(self, ui: &mut Ui) -> Response { + let LegendEntry { + checked, + text, + color, + .. + } = self; + let icon_width = ui.spacing().icon_width; + let icon_spacing = ui.spacing().icon_spacing; + let padding = vec2(2.0, 2.0); + let total_extra = padding + vec2(icon_width + icon_spacing, 0.0) + padding; + + let text_style = TextStyle::Button; + let galley = ui.fonts().layout_no_wrap(text_style, text.clone()); + + let mut desired_size = total_extra + galley.size; + desired_size = desired_size.at_least(ui.spacing().interact_size); + desired_size.y = desired_size.y.at_least(icon_width); + + let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); + let rect = rect.shrink2(padding); + + response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text)); + + let visuals = ui.style().interact(&response); + + let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); + + let painter = ui.painter(); + + painter.add(Shape::Circle { + center: big_icon_rect.center(), + radius: big_icon_rect.width() / 2.0 + visuals.expansion, + fill: visuals.bg_fill, + stroke: visuals.bg_stroke, + }); + + if *checked { + painter.add(Shape::Circle { + center: small_icon_rect.center(), + radius: small_icon_rect.width() * 0.8, + fill: *color, + stroke: Default::default(), + }); + } + + let text_position = pos2( + rect.left() + padding.x + icon_width + icon_spacing, + rect.center().y - 0.5 * galley.size.y, + ); + painter.galley(text_position, galley, visuals.text_color()); + + self.checked ^= response.clicked_by(PointerButton::Primary); + self.hovered = response.hovered(); + + response + } +} diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 080c347ca3a..bfda24c63f2 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -1,8 +1,11 @@ //! Simple plotting library. mod items; +mod legend; mod transform; +use std::collections::{BTreeMap, HashSet}; + pub use items::{Curve, Value}; use items::{HLine, VLine}; use transform::{Bounds, ScreenTransform}; @@ -10,6 +13,8 @@ use transform::{Bounds, ScreenTransform}; use crate::*; use color::Hsva; +use self::legend::LegendEntry; + // ---------------------------------------------------------------------------- /// Information about the plot that has to persist between frames. @@ -18,6 +23,7 @@ use color::Hsva; struct PlotMemory { bounds: Bounds, auto_bounds: bool, + hidden_curves: HashSet, } // ---------------------------------------------------------------------------- @@ -61,6 +67,7 @@ pub struct Plot { show_x: bool, show_y: bool, + show_legend: bool, } impl Plot { @@ -89,6 +96,7 @@ impl Plot { show_x: true, show_y: true, + show_legend: true, } } @@ -229,6 +237,12 @@ impl Plot { self.min_auto_bounds.extend_with_y(y.into()); self } + + /// Whether to show a legend including all named curves. Default: `true`. + pub fn show_legend(mut self, show: bool) -> Self { + self.show_legend = show; + self + } } impl Widget for Plot { @@ -250,8 +264,9 @@ impl Widget for Plot { min_size, data_aspect, view_aspect, - show_x, - show_y, + mut show_x, + mut show_y, + show_legend, } = self; let plot_id = ui.make_persistent_id(name); @@ -260,46 +275,110 @@ impl Widget for Plot { .id_data .get_mut_or_insert_with(plot_id, || PlotMemory { bounds: min_auto_bounds, - auto_bounds: true, + auto_bounds: !min_auto_bounds.is_valid(), + hidden_curves: HashSet::new(), }) .clone(); let PlotMemory { mut bounds, mut auto_bounds, + mut hidden_curves, } = memory; + // Determine the size of the plot in the UI let size = { - let width = width.unwrap_or_else(|| { - if let (Some(height), Some(aspect)) = (height, view_aspect) { - height * aspect - } else { - ui.available_size_before_wrap_finite().x - } - }); - let width = width.at_least(min_size.x); - - let height = height.unwrap_or_else(|| { - if let Some(aspect) = view_aspect { - width / aspect - } else { - ui.available_size_before_wrap_finite().y - } - }); - let height = height.at_least(min_size.y); + let width = width + .unwrap_or_else(|| { + if let (Some(height), Some(aspect)) = (height, view_aspect) { + height * aspect + } else { + ui.available_size_before_wrap_finite().x + } + }) + .at_least(min_size.x); + + let height = height + .unwrap_or_else(|| { + if let Some(aspect) = view_aspect { + width / aspect + } else { + ui.available_size_before_wrap_finite().y + } + }) + .at_least(min_size.y); vec2(width, height) }; let (rect, response) = ui.allocate_exact_size(size, Sense::drag()); + let plot_painter = ui.painter().sub_region(rect); // Background - ui.painter().add(Shape::Rect { + plot_painter.add(Shape::Rect { rect, corner_radius: 2.0, fill: ui.visuals().extreme_bg_color, stroke: ui.visuals().window_stroke(), }); + // --- Legend --- + + if show_legend { + // Collect the legend entries. If multiple curves have the same name, they share a + // checkbox. If their colors don't match, we pick a neutral color for the checkbox. + let mut legend_entries: BTreeMap = BTreeMap::new(); + curves + .iter() + .filter(|curve| !curve.name.is_empty()) + .for_each(|curve| { + let checked = !hidden_curves.contains(&curve.name); + let text = curve.name.clone(); + legend_entries + .entry(curve.name.clone()) + .and_modify(|entry| { + if entry.color != curve.stroke.color { + entry.color = ui.visuals().noninteractive().fg_stroke.color + } + }) + .or_insert_with(|| LegendEntry::new(text, curve.stroke.color, checked)); + }); + + // Show the legend. + let mut legend_ui = ui.child_ui(rect, Layout::top_down(Align::LEFT)); + legend_entries.values_mut().for_each(|entry| { + let response = legend_ui.add(entry); + if response.hovered() { + show_x = false; + show_y = false; + } + }); + + // Get the names of the hidden curves. + hidden_curves = legend_entries + .values() + .filter(|entry| !entry.checked) + .map(|entry| entry.text.clone()) + .collect(); + + // Highlight the hovered curves. + legend_entries + .values() + .filter(|entry| entry.hovered) + .for_each(|entry| { + curves + .iter_mut() + .filter(|curve| curve.name == entry.text) + .for_each(|curve| { + curve.stroke.width *= 2.0; + }); + }); + + // Remove deselected curves. + curves.retain(|curve| !hidden_curves.contains(&curve.name)); + } + + // --- + auto_bounds |= response.double_clicked_by(PointerButton::Primary); // Set bounds automatically based on content. @@ -358,13 +437,7 @@ impl Widget for Plot { .iter_mut() .for_each(|curve| curve.generate_points(transform.bounds().range_x())); - ui.memory().id_data.insert( - plot_id, - PlotMemory { - bounds: *transform.bounds(), - auto_bounds, - }, - ); + let bounds = *transform.bounds(); let prepared = Prepared { curves, @@ -376,7 +449,20 @@ impl Widget for Plot { }; prepared.ui(ui, &response); - response.on_hover_cursor(CursorIcon::Crosshair) + ui.memory().id_data.insert( + plot_id, + PlotMemory { + bounds, + auto_bounds, + hidden_curves, + }, + ); + + if show_x || show_y { + response.on_hover_cursor(CursorIcon::Crosshair) + } else { + response + } } } diff --git a/egui/src/widgets/plot/transform.rs b/egui/src/widgets/plot/transform.rs index 6f9d131807a..c9b82183cf7 100644 --- a/egui/src/widgets/plot/transform.rs +++ b/egui/src/widgets/plot/transform.rs @@ -118,6 +118,7 @@ impl Bounds { } /// Contains the screen rectangle and the plot bounds and provides methods to transform them. +#[derive(Clone)] pub(crate) struct ScreenTransform { /// The screen rectangle. frame: Rect, diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index 3c332f7908c..7e6aa12e2e1 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -9,6 +9,7 @@ pub struct PlotDemo { circle_radius: f64, circle_center: Pos2, square: bool, + legend: bool, proportional: bool, } @@ -20,6 +21,7 @@ impl Default for PlotDemo { circle_radius: 1.5, circle_center: Pos2::new(0.0, 0.0), square: false, + legend: true, proportional: true, } } @@ -54,6 +56,7 @@ impl PlotDemo { circle_radius, circle_center, square, + legend, proportional, } = self; @@ -87,6 +90,7 @@ impl PlotDemo { ui.checkbox(animate, "animate"); ui.add_space(8.0); ui.checkbox(square, "square view"); + ui.checkbox(legend, "legend"); ui.checkbox(proportional, "proportional data axes"); }); }); @@ -145,7 +149,8 @@ impl super::View for PlotDemo { .curve(self.circle()) .curve(self.sin()) .curve(self.thingy()) - .min_size(Vec2::new(256.0, 200.0)); + .show_legend(self.legend) + .min_size(Vec2::new(200.0, 200.0)); if self.square { plot = plot.view_aspect(1.0); }