diff --git a/CHANGELOG.md b/CHANGELOG.md index 165c2702e1e..c3d6217fa39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ NOTE: [`eframe`](eframe/CHANGELOG.md), [`egui_web`](egui_web/CHANGELOG.md) and [ ## Unreleased ### Added ⭐ +* [Plot legend improvements](https://github.com/emilk/egui/pull/410). * [Line markers for plots](https://github.com/emilk/egui/pull/363). * Add right and bottom panels (`SidePanel::right` and `Panel::bottom`). * Add resizable panels. diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items.rs index 3f881ab73e3..9e49c476b8c 100644 --- a/egui/src/widgets/plot/items.rs +++ b/egui/src/widgets/plot/items.rs @@ -69,6 +69,7 @@ pub(super) trait PlotItem { fn name(&self) -> &str; fn color(&self) -> Color32; fn highlight(&mut self); + fn highlighted(&self) -> bool; } // ---------------------------------------------------------------------------- @@ -273,7 +274,8 @@ impl Line { /// Name of this line. /// - /// This name will show up in the plot legend, if legends are turned on. + /// This name will show up in the plot legend, if legends are turned on. Multiple lines may + /// share the same name, in which case they will also share an entry in the legend. #[allow(clippy::needless_pass_by_value)] pub fn name(mut self, name: impl ToString) -> Self { self.name = name.to_string(); @@ -327,6 +329,10 @@ impl PlotItem for Line { fn highlight(&mut self) { self.highlight = true; } + + fn highlighted(&self) -> bool { + self.highlight + } } /// A set of points. @@ -386,9 +392,10 @@ impl Points { self } - /// Name of this series of markers. + /// Name of this set of points. /// - /// This name will show up in the plot legend, if legends are turned on. + /// This name will show up in the plot legend, if legends are turned on. Multiple sets of points + /// may share the same name, in which case they will also share an entry in the legend. #[allow(clippy::needless_pass_by_value)] pub fn name(mut self, name: impl ToString) -> Self { self.name = name.to_string(); @@ -556,4 +563,8 @@ impl PlotItem for Points { fn highlight(&mut self) { self.highlight = true; } + + fn highlighted(&self) -> bool { + self.highlight + } } diff --git a/egui/src/widgets/plot/legend.rs b/egui/src/widgets/plot/legend.rs index af3754f1d6b..3f29d025600 100644 --- a/egui/src/widgets/plot/legend.rs +++ b/egui/src/widgets/plot/legend.rs @@ -1,81 +1,237 @@ -use std::string::String; +use std::{ + collections::{BTreeMap, HashSet}, + string::String, +}; use crate::*; -pub(crate) struct LegendEntry { - pub text: String, - pub color: Color32, - pub checked: bool, - pub hovered: bool, +use super::items::PlotItem; + +/// Where to place the plot legend. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Corner { + LeftTop, + RightTop, + LeftBottom, + RightBottom, +} + +impl Corner { + pub fn all() -> impl Iterator { + [ + Corner::LeftTop, + Corner::RightTop, + Corner::LeftBottom, + Corner::RightBottom, + ] + .iter() + .copied() + } +} + +/// The configuration for a plot legend. +#[derive(Clone, Copy, PartialEq)] +pub struct Legend { + pub text_style: TextStyle, + pub position: Corner, +} + +impl Default for Legend { + fn default() -> Self { + Self { + text_style: TextStyle::Body, + position: Corner::RightTop, + } + } +} + +impl Legend { + pub fn text_style(mut self, style: TextStyle) -> Self { + self.text_style = style; + self + } + + pub fn position(mut self, corner: Corner) -> Self { + self.position = corner; + self + } +} + +#[derive(Clone)] +struct LegendEntry { + color: Color32, + checked: bool, + hovered: bool, } impl LegendEntry { - pub fn new(text: String, color: Color32, checked: bool) -> Self { + fn new(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, + fn ui(&mut self, ui: &mut Ui, text: String) -> Response { + let Self { color, - .. + checked, + hovered, } = 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 galley = ui.fonts().layout_no_wrap(ui.style().body_text_style, text); - 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 icon_size = galley.size.y; + let icon_spacing = icon_size / 5.0; + let total_extra = vec2(icon_size + icon_spacing, 0.0); + let desired_size = total_extra + galley.size; 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 label_on_the_left = ui.layout().horizontal_align() == Align::RIGHT; - let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); + let icon_position_x = if label_on_the_left { + rect.right() - icon_size / 2.0 + } else { + rect.left() + icon_size / 2.0 + }; + let icon_position = pos2(icon_position_x, rect.center().y); + let icon_rect = Rect::from_center_size(icon_position, vec2(icon_size, icon_size)); let painter = ui.painter(); painter.add(Shape::Circle { - center: big_icon_rect.center(), - radius: big_icon_rect.width() / 2.0 + visuals.expansion, + center: icon_rect.center(), + radius: icon_size * 0.5, fill: visuals.bg_fill, stroke: visuals.bg_stroke, }); if *checked { + let fill = if *color == Color32::TRANSPARENT { + ui.visuals().noninteractive().fg_stroke.color + } else { + *color + }; painter.add(Shape::Circle { - center: small_icon_rect.center(), - radius: small_icon_rect.width() * 0.8, - fill: *color, + center: icon_rect.center(), + radius: icon_size * 0.4, + fill, stroke: Default::default(), }); } - let text_position = pos2( - rect.left() + padding.x + icon_width + icon_spacing, - rect.center().y - 0.5 * galley.size.y, - ); + let text_position_x = if label_on_the_left { + rect.right() - icon_size - icon_spacing - galley.size.x + } else { + rect.left() + icon_size + icon_spacing + }; + + let text_position = pos2(text_position_x, 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(); + *checked ^= response.clicked_by(PointerButton::Primary); + *hovered = response.hovered(); response } } + +#[derive(Clone)] +pub(super) struct LegendWidget { + rect: Rect, + entries: BTreeMap, + config: Legend, +} + +impl LegendWidget { + /// Create a new legend from items, the names of items that are hidden and the style of the + /// text. Returns `None` if the legend has no entries. + pub(super) fn try_new( + rect: Rect, + config: Legend, + items: &[Box], + hidden_items: &HashSet, + ) -> Option { + // Collect the legend entries. If multiple items have the same name, they share a + // checkbox. If their colors don't match, we pick a neutral color for the checkbox. + let mut entries: BTreeMap = BTreeMap::new(); + items + .iter() + .filter(|item| !item.name().is_empty()) + .for_each(|item| { + entries + .entry(item.name().to_string()) + .and_modify(|entry| { + if entry.color != item.color() { + // Multiple items with different colors + entry.color = Color32::TRANSPARENT; + } + }) + .or_insert_with(|| { + let color = item.color(); + let checked = !hidden_items.contains(item.name()); + LegendEntry::new(color, checked) + }); + }); + (!entries.is_empty()).then(|| Self { + rect, + entries, + config, + }) + } + + // Get the names of the hidden items. + pub fn get_hidden_items(&self) -> HashSet { + self.entries + .iter() + .filter(|(_, entry)| !entry.checked) + .map(|(name, _)| name.clone()) + .collect() + } + + // Get the name of the hovered items. + pub fn get_hovered_entry_name(&self) -> Option { + self.entries + .iter() + .find(|(_, entry)| entry.hovered) + .map(|(name, _)| name.to_string()) + } +} + +impl Widget for &mut LegendWidget { + fn ui(self, ui: &mut Ui) -> Response { + let LegendWidget { + rect, + entries, + config, + } = self; + + let main_dir = match config.position { + Corner::LeftTop | Corner::RightTop => Direction::TopDown, + Corner::LeftBottom | Corner::RightBottom => Direction::BottomUp, + }; + let cross_align = match config.position { + Corner::LeftTop | Corner::LeftBottom => Align::LEFT, + Corner::RightTop | Corner::RightBottom => Align::RIGHT, + }; + let layout = Layout::from_main_dir_and_cross_align(main_dir, cross_align); + let legend_pad = 2.0; + let legend_rect = rect.shrink(legend_pad); + let mut legend_ui = ui.child_ui(legend_rect, layout); + legend_ui + .scope(|ui| { + ui.style_mut().body_text_style = config.text_style; + entries + .iter_mut() + .map(|(name, entry)| entry.ui(ui, name.clone())) + .reduce(|r1, r2| r1.union(r2)) + .unwrap() + }) + .inner + } +} diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 33e51f8a156..902355eed83 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -4,12 +4,13 @@ mod items; mod legend; mod transform; -use std::collections::{BTreeMap, HashSet}; +use std::collections::HashSet; use items::PlotItem; pub use items::{HLine, VLine}; pub use items::{Line, MarkerShape, Points, Value, Values}; -use legend::LegendEntry; +use legend::LegendWidget; +pub use legend::{Corner, Legend}; use transform::{Bounds, ScreenTransform}; use crate::*; @@ -23,6 +24,7 @@ use color::Hsva; struct PlotMemory { bounds: Bounds, auto_bounds: bool, + hovered_entry: Option, hidden_items: HashSet, } @@ -67,7 +69,7 @@ pub struct Plot { show_x: bool, show_y: bool, - show_legend: bool, + legend_config: Option, } impl Plot { @@ -96,7 +98,7 @@ impl Plot { show_x: true, show_y: true, - show_legend: true, + legend_config: None, } } @@ -262,9 +264,16 @@ impl Plot { self } + #[deprecated = "Use `Plot::legend` instead"] /// Whether to show a legend including all named items. Default: `true`. pub fn show_legend(mut self, show: bool) -> Self { - self.show_legend = show; + self.legend_config = show.then(Legend::default); + self + } + + /// Show a legend including all named items. + pub fn legend(mut self, legend: Legend) -> Self { + self.legend_config = Some(legend); self } } @@ -290,7 +299,7 @@ impl Widget for Plot { view_aspect, mut show_x, mut show_y, - show_legend, + legend_config, } = self; let plot_id = ui.make_persistent_id(name); @@ -300,6 +309,7 @@ impl Widget for Plot { .get_mut_or_insert_with(plot_id, || PlotMemory { bounds: min_auto_bounds, auto_bounds: !min_auto_bounds.is_valid(), + hovered_entry: None, hidden_items: HashSet::new(), }) .clone(); @@ -307,6 +317,7 @@ impl Widget for Plot { let PlotMemory { mut bounds, mut auto_bounds, + mut hovered_entry, mut hidden_items, } = memory; @@ -345,65 +356,25 @@ impl Widget for Plot { stroke: ui.visuals().window_stroke(), }); - // --- Legend --- - - if show_legend { - // Collect the legend entries. If multiple items 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(); - let neutral_color = ui.visuals().noninteractive().fg_stroke.color; + // Legend + let legend = legend_config + .and_then(|config| LegendWidget::try_new(rect, config, &items, &hidden_items)); + // Don't show hover cursor when hovering over legend. + if hovered_entry.is_some() { + show_x = false; + show_y = false; + } + // Remove the deselected items. + items.retain(|item| !hidden_items.contains(item.name())); + // Highlight the hovered items. + if let Some(hovered_name) = &hovered_entry { items - .iter() - .filter(|item| !item.name().is_empty()) - .for_each(|item| { - let checked = !hidden_items.contains(item.name()); - let text = item.name(); - legend_entries - .entry(item.name().to_string()) - .and_modify(|entry| { - if entry.color != item.color() { - entry.color = neutral_color - } - }) - .or_insert_with(|| { - LegendEntry::new(text.to_string(), item.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 items. - hidden_items = legend_entries - .values() - .filter(|entry| !entry.checked) - .map(|entry| entry.text.clone()) - .collect(); - - // Highlight the hovered items. - legend_entries - .values() - .filter(|entry| entry.hovered) - .for_each(|entry| { - items.iter_mut().for_each(|item| { - if item.name() == entry.text { - item.highlight(); - } - }); - }); - - // Remove deselected items. - items.retain(|item| !hidden_items.contains(item.name())); + .iter_mut() + .filter(|entry| entry.name() == hovered_name) + .for_each(|entry| entry.highlight()); } - - // --- + // Move highlighted items to front. + items.sort_by_key(|item| item.highlighted()); auto_bounds |= response.double_clicked_by(PointerButton::Primary); @@ -482,11 +453,18 @@ impl Widget for Plot { }; prepared.ui(ui, &response); + if let Some(mut legend) = legend { + ui.add(&mut legend); + hidden_items = legend.get_hidden_items(); + hovered_entry = legend.get_hovered_entry_name(); + } + ui.memory().id_data.insert( plot_id, PlotMemory { bounds, auto_bounds, + hovered_entry, hidden_items, }, ); diff --git a/egui_demo_lib/src/apps/demo/plot_demo.rs b/egui_demo_lib/src/apps/demo/plot_demo.rs index 0b59fd08551..6cf73a53431 100644 --- a/egui_demo_lib/src/apps/demo/plot_demo.rs +++ b/egui_demo_lib/src/apps/demo/plot_demo.rs @@ -1,5 +1,5 @@ -use egui::plot::{Line, MarkerShape, Plot, Points, Value, Values}; use egui::*; +use plot::{Corner, Legend, Line, MarkerShape, Plot, Points, Value, Values}; use std::f64::consts::TAU; #[derive(PartialEq)] @@ -9,7 +9,6 @@ struct LineDemo { circle_radius: f64, circle_center: Pos2, square: bool, - legend: bool, proportional: bool, } @@ -21,7 +20,6 @@ impl Default for LineDemo { circle_radius: 1.5, circle_center: Pos2::new(0.0, 0.0), square: false, - legend: true, proportional: true, } } @@ -35,7 +33,6 @@ impl LineDemo { circle_radius, circle_center, square, - legend, proportional, .. } = self; @@ -69,7 +66,6 @@ impl LineDemo { ui.style_mut().wrap = Some(false); ui.checkbox(animate, "animate"); ui.checkbox(square, "square view"); - ui.checkbox(legend, "legend"); ui.checkbox(proportional, "proportional data axes"); }); }); @@ -124,7 +120,7 @@ impl Widget for &mut LineDemo { .line(self.circle()) .line(self.sin()) .line(self.thingy()) - .show_legend(self.legend); + .legend(Legend::default()); if self.square { plot = plot.view_aspect(1.0); } @@ -200,7 +196,9 @@ impl Widget for &mut MarkerDemo { } }); - let mut markers_plot = Plot::new("Markers Demo").data_aspect(1.0); + let mut markers_plot = Plot::new("Markers Demo") + .data_aspect(1.0) + .legend(Legend::default()); for marker in self.markers() { markers_plot = markers_plot.points(marker); } @@ -208,10 +206,75 @@ impl Widget for &mut MarkerDemo { } } +#[derive(PartialEq)] +struct LegendDemo { + config: Legend, +} + +impl Default for LegendDemo { + fn default() -> Self { + Self { + config: Legend::default(), + } + } +} + +impl LegendDemo { + fn line_with_slope(slope: f64) -> Line { + Line::new(Values::from_explicit_callback( + move |x| slope * x, + f64::NEG_INFINITY..=f64::INFINITY, + 100, + )) + } + fn sin() -> Line { + Line::new(Values::from_explicit_callback( + move |x| x.sin(), + f64::NEG_INFINITY..=f64::INFINITY, + 100, + )) + } + fn cos() -> Line { + Line::new(Values::from_explicit_callback( + move |x| x.cos(), + f64::NEG_INFINITY..=f64::INFINITY, + 100, + )) + } +} + +impl Widget for &mut LegendDemo { + fn ui(self, ui: &mut Ui) -> Response { + let LegendDemo { config } = self; + + ui.label("Text Style:"); + ui.horizontal(|ui| { + TextStyle::all().for_each(|style| { + ui.selectable_value(&mut config.text_style, style, format!("{:?}", style)); + }); + }); + ui.label("Position:"); + ui.horizontal(|ui| { + Corner::all().for_each(|position| { + ui.selectable_value(&mut config.position, position, format!("{:?}", position)); + }); + }); + let legend_plot = Plot::new("Legend Demo") + .line(LegendDemo::line_with_slope(0.5).name("lines")) + .line(LegendDemo::line_with_slope(1.0).name("lines")) + .line(LegendDemo::line_with_slope(2.0).name("lines")) + .line(LegendDemo::sin().name("sin(x)")) + .line(LegendDemo::cos().name("cos(x)")) + .legend(*config) + .data_aspect(1.0); + ui.add(legend_plot) + } +} #[derive(PartialEq, Eq)] enum Panel { Lines, Markers, + Legend, } impl Default for Panel { @@ -224,6 +287,7 @@ impl Default for Panel { pub struct PlotDemo { line_demo: LineDemo, marker_demo: MarkerDemo, + legend_demo: LegendDemo, open_panel: Panel, } @@ -261,6 +325,7 @@ impl super::View for PlotDemo { ui.horizontal(|ui| { ui.selectable_value(&mut self.open_panel, Panel::Lines, "Lines"); ui.selectable_value(&mut self.open_panel, Panel::Markers, "Markers"); + ui.selectable_value(&mut self.open_panel, Panel::Legend, "Legend"); }); ui.separator(); @@ -271,6 +336,9 @@ impl super::View for PlotDemo { Panel::Markers => { ui.add(&mut self.marker_demo); } + Panel::Legend => { + ui.add(&mut self.legend_demo); + } } } }