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#[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#[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 pub fn text_style(mut self, style: TextStyle) -> Self {
51 self.text_style = style;
52 self
53 }
54
55 pub fn background_alpha(mut self, alpha: f32) -> Self {
57 self.background_alpha = alpha;
58 self
59 }
60
61 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 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 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 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 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 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}