maelstrom_plot/
legend.rs

1use super::items::PlotItem;
2use egui::{
3    ahash, epaint, pos2, vec2, Align, Color32, Direction, Frame, Layout, PointerButton, Rect,
4    Response, Sense, TextStyle, Ui, Widget, WidgetInfo, WidgetType,
5};
6use std::{collections::BTreeMap, string::String};
7
8/// Where to place the plot legend.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Corner {
11    LeftTop,
12    RightTop,
13    LeftBottom,
14    RightBottom,
15}
16
17impl Corner {
18    pub fn all() -> impl Iterator<Item = Corner> {
19        [
20            Corner::LeftTop,
21            Corner::RightTop,
22            Corner::LeftBottom,
23            Corner::RightBottom,
24        ]
25        .iter()
26        .copied()
27    }
28}
29
30/// The configuration for a plot legend.
31#[derive(Clone, PartialEq)]
32pub struct Legend {
33    pub text_style: TextStyle,
34    pub background_alpha: f32,
35    pub position: Corner,
36}
37
38impl Default for Legend {
39    fn default() -> Self {
40        Self {
41            text_style: TextStyle::Body,
42            background_alpha: 0.75,
43            position: Corner::RightTop,
44        }
45    }
46}
47
48impl Legend {
49    /// Which text style to use for the legend. Default: `TextStyle::Body`.
50    pub fn text_style(mut self, style: TextStyle) -> Self {
51        self.text_style = style;
52        self
53    }
54
55    /// The alpha of the legend background. Default: `0.75`.
56    pub fn background_alpha(mut self, alpha: f32) -> Self {
57        self.background_alpha = alpha;
58        self
59    }
60
61    /// In which corner to place the legend. Default: `Corner::RightTop`.
62    pub fn position(mut self, corner: Corner) -> Self {
63        self.position = corner;
64        self
65    }
66}
67
68#[derive(Clone)]
69struct LegendEntry {
70    color: Color32,
71    checked: bool,
72    hovered: bool,
73}
74
75impl LegendEntry {
76    fn new(color: Color32, checked: bool) -> Self {
77        Self {
78            color,
79            checked,
80            hovered: false,
81        }
82    }
83
84    fn ui(&mut self, ui: &mut Ui, text: String, text_style: &TextStyle) -> Response {
85        let Self {
86            color,
87            checked,
88            hovered,
89        } = self;
90
91        let font_id = text_style.resolve(ui.style());
92
93        let galley = ui.fonts(|f| f.layout_delayed_color(text, font_id, f32::INFINITY));
94
95        let icon_size = galley.size().y;
96        let icon_spacing = icon_size / 5.0;
97        let total_extra = vec2(icon_size + icon_spacing, 0.0);
98
99        let desired_size = total_extra + galley.size();
100        let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click());
101
102        response
103            .widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, galley.text()));
104
105        let visuals = ui.style().interact(&response);
106        let label_on_the_left = ui.layout().horizontal_placement() == Align::RIGHT;
107
108        let icon_position_x = if label_on_the_left {
109            rect.right() - icon_size / 2.0
110        } else {
111            rect.left() + icon_size / 2.0
112        };
113        let icon_position = pos2(icon_position_x, rect.center().y);
114        let icon_rect = Rect::from_center_size(icon_position, vec2(icon_size, icon_size));
115
116        let painter = ui.painter();
117
118        painter.add(epaint::CircleShape {
119            center: icon_rect.center(),
120            radius: icon_size * 0.5,
121            fill: visuals.bg_fill,
122            stroke: visuals.bg_stroke,
123        });
124
125        if *checked {
126            let fill = if *color == Color32::TRANSPARENT {
127                ui.visuals().noninteractive().fg_stroke.color
128            } else {
129                *color
130            };
131            painter.add(epaint::Shape::circle_filled(
132                icon_rect.center(),
133                icon_size * 0.4,
134                fill,
135            ));
136        }
137
138        let text_position_x = if label_on_the_left {
139            rect.right() - icon_size - icon_spacing - galley.size().x
140        } else {
141            rect.left() + icon_size + icon_spacing
142        };
143
144        let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size().y);
145        painter.galley(text_position, galley, visuals.text_color());
146
147        *checked ^= response.clicked_by(PointerButton::Primary);
148        *hovered = response.hovered();
149
150        response
151    }
152}
153
154#[derive(Clone)]
155pub(super) struct LegendWidget {
156    rect: Rect,
157    entries: BTreeMap<String, LegendEntry>,
158    config: Legend,
159}
160
161impl LegendWidget {
162    /// Create a new legend from items, the names of items that are hidden and the style of the
163    /// text. Returns `None` if the legend has no entries.
164    pub(super) fn try_new(
165        rect: Rect,
166        config: Legend,
167        items: &[Box<dyn PlotItem>],
168        hidden_items: &ahash::HashSet<String>,
169    ) -> Option<Self> {
170        // Collect the legend entries. If multiple items have the same name, they share a
171        // checkbox. If their colors don't match, we pick a neutral color for the checkbox.
172        let mut entries: BTreeMap<String, LegendEntry> = BTreeMap::new();
173        items
174            .iter()
175            .filter(|item| !item.name().is_empty())
176            .for_each(|item| {
177                entries
178                    .entry(item.name().to_owned())
179                    .and_modify(|entry| {
180                        if entry.color != item.color() {
181                            // Multiple items with different colors
182                            entry.color = Color32::TRANSPARENT;
183                        }
184                    })
185                    .or_insert_with(|| {
186                        let color = item.color();
187                        let checked = !hidden_items.contains(item.name());
188                        LegendEntry::new(color, checked)
189                    });
190            });
191        (!entries.is_empty()).then_some(Self {
192            rect,
193            entries,
194            config,
195        })
196    }
197
198    // Get the names of the hidden items.
199    pub fn hidden_items(&self) -> ahash::HashSet<String> {
200        self.entries
201            .iter()
202            .filter(|(_, entry)| !entry.checked)
203            .map(|(name, _)| name.clone())
204            .collect()
205    }
206
207    // Get the name of the hovered items.
208    pub fn hovered_entry_name(&self) -> Option<String> {
209        self.entries
210            .iter()
211            .find(|(_, entry)| entry.hovered)
212            .map(|(name, _)| name.to_string())
213    }
214}
215
216impl Widget for &mut LegendWidget {
217    fn ui(self, ui: &mut Ui) -> Response {
218        let LegendWidget {
219            rect,
220            entries,
221            config,
222        } = self;
223
224        let main_dir = match config.position {
225            Corner::LeftTop | Corner::RightTop => Direction::TopDown,
226            Corner::LeftBottom | Corner::RightBottom => Direction::BottomUp,
227        };
228        let cross_align = match config.position {
229            Corner::LeftTop | Corner::LeftBottom => Align::LEFT,
230            Corner::RightTop | Corner::RightBottom => Align::RIGHT,
231        };
232        let layout = Layout::from_main_dir_and_cross_align(main_dir, cross_align);
233        let legend_pad = 4.0;
234        let legend_rect = rect.shrink(legend_pad);
235        let mut legend_ui = ui.child_ui(legend_rect, layout);
236        legend_ui
237            .scope(|ui| {
238                let background_frame = Frame {
239                    inner_margin: vec2(8.0, 4.0).into(),
240                    rounding: ui.style().visuals.window_rounding,
241                    shadow: egui::epaint::Shadow::NONE,
242                    fill: ui.style().visuals.extreme_bg_color,
243                    stroke: ui.style().visuals.window_stroke(),
244                    ..Default::default()
245                }
246                .multiply_with_opacity(config.background_alpha);
247                background_frame
248                    .show(ui, |ui| {
249                        entries
250                            .iter_mut()
251                            .map(|(name, entry)| entry.ui(ui, name.clone(), &config.text_style))
252                            .reduce(|r1, r2| r1.union(r2))
253                            .unwrap()
254                    })
255                    .inner
256            })
257            .inner
258    }
259}