1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
use egui::{Align2, Color32, Frame, Id, NumExt as _, Sense, Shadow, Stroke, Ui, UiBuilder, Vec2};
use crate::UiExt as _;
use crate::egui_ext::Group;
/// Configuration for the legend container widget.
pub struct LegendConfig {
/// Where should the legend be shown within the plot?
pub position: Align2,
/// The base ID used to derive a predictable frame ID for the legend.
/// Use [`legend_frame_id`] with the same ID to check hover state from outside.
pub id: Id,
}
impl Default for LegendConfig {
fn default() -> Self {
Self {
position: Align2::RIGHT_TOP,
id: Id::new("plot_legend"),
}
}
}
/// Returns the ID used for the legend's frame.
pub fn legend_frame_id(id: Id) -> Id {
id.with("legend_frame")
}
/// A standalone plot legend container.
pub struct LegendWidget {
config: LegendConfig,
}
impl LegendWidget {
pub fn new(config: LegendConfig) -> Self {
Self { config }
}
/// Render the legend container overlaid on the given UI.
pub fn show(&self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui)) {
let frame_id = legend_frame_id(self.config.id);
Group::new("legend")
.align2(self.config.position)
.show(ui, |ui| {
Frame::popup(ui.style())
.outer_margin(4)
.inner_margin(4)
.shadow(Shadow::NONE)
.show(ui, |ui| {
ui.scope_builder(UiBuilder::new().id(frame_id), |ui| {
ui.set_max_width(300.0);
let max_height = (ui.available_height() * 0.8).at_most(300.0);
egui::ScrollArea::vertical()
.max_height(max_height)
.show(ui, |ui| {
ui.with_layout(
egui::Layout::top_down(egui::Align::LEFT),
add_contents,
);
});
});
});
});
}
/// High-level API: render legend entries with built-in click-toggle and Alt+click solo/restore.
///
/// Accepts a flat iterator of [`LegendEntry`] values (one per series/item).
/// Entries sharing the same label are grouped into a single legend row.
/// The returned [`LegendOutput::hidden_ids`] covers all IDs that should be hidden.
pub fn show_entries(
&self,
ui: &mut Ui,
entries: impl IntoIterator<Item = LegendEntry>,
) -> LegendOutput {
// Group flat entries by label, preserving insertion order.
let mut entry_map: indexmap::IndexMap<String, LegendEntryWidget> =
indexmap::IndexMap::new();
for e in entries {
entry_map
.entry(e.label.clone())
.and_modify(|w| w.ids.push(e.id))
.or_insert_with(|| LegendEntryWidget {
label: e.label,
color: e.color,
visible: e.visible,
hovered: e.hovered,
ids: vec![e.id],
});
}
let grouped: Vec<LegendEntryWidget> = entry_map.into_values().collect();
if grouped.is_empty() {
return LegendOutput {
hovered_id: None,
hidden_ids: egui::IdSet::default(),
};
}
let mut hovered_id: Option<Id> = None;
let mut toggled_labels: egui::ahash::HashSet<&str> = egui::ahash::HashSet::default();
let mut focus_label: Option<&str> = None;
self.show(ui, |ui| {
for entry in &grouped {
let response = entry.show(ui);
if response.hovered() {
hovered_id = entry.ids.first().copied();
}
if response.clicked() {
if ui.input(|r| r.modifiers.alt) {
focus_label = Some(entry.label.as_str());
} else {
toggled_labels.insert(entry.label.as_str());
}
}
}
});
let hidden_ids = if let Some(focus) = focus_label {
let already_solo = grouped
.iter()
.all(|e| e.visible == (e.label.as_str() == focus));
if already_solo {
egui::IdSet::default()
} else {
grouped
.iter()
.filter(|e| e.label.as_str() != focus)
.flat_map(|e| e.ids.iter().copied())
.collect()
}
} else {
grouped
.iter()
.filter(|e| {
let was_clicked = toggled_labels.contains(e.label.as_str());
let now_visible = e.visible != was_clicked;
!now_visible
})
.flat_map(|e| e.ids.iter().copied())
.collect()
};
LegendOutput {
hovered_id,
hidden_ids,
}
}
}
/// Result of [`LegendWidget::show_entries`].
pub struct LegendOutput {
/// ID of the hovered legend entry, if any.
pub hovered_id: Option<Id>,
/// IDs (from the input entries) that should be hidden after processing clicks this frame.
pub hidden_ids: egui::IdSet,
}
/// Flat input for a single series/item. Pass an iterator of these to
/// [`LegendWidget::show_entries`], which groups them by label internally.
pub struct LegendEntry {
pub id: Id,
pub label: String,
pub color: Color32,
pub visible: bool,
pub hovered: bool,
}
/// A single legend row (one per unique label). Built internally by [`LegendWidget::show_entries`].
struct LegendEntryWidget {
label: String,
color: Color32,
visible: bool,
hovered: bool,
ids: Vec<Id>,
}
impl LegendEntryWidget {
fn show(&self, ui: &mut Ui) -> egui::Response {
let swatch_id = Id::new(&self.label).with("swatch");
let swatch_size = Vec2::splat(8.0);
let tokens = ui.tokens();
let text_color = if self.hovered {
tokens.list_item_strong_text
} else if self.visible {
tokens.list_item_noninteractive_text
} else {
tokens.list_item_noninteractive_text.gamma_multiply(0.5)
};
let display_color = if self.visible {
self.color
} else {
self.color.gamma_multiply(0.5)
};
let text = egui::RichText::new(&self.label).color(text_color);
let atoms = egui::Atoms::new((egui::Atom::custom(swatch_id, swatch_size), text));
let mut atom_layout = egui::AtomLayout::new(atoms)
.gap(4.0)
.frame(Frame::NONE.inner_margin(egui::Margin::symmetric(4, 0)))
.sense(Sense::click())
.allocate(ui);
atom_layout.response = atom_layout
.response
.on_hover_cursor(egui::CursorIcon::PointingHand);
let atom_response = atom_layout.paint(ui);
// Paint the color dot / outline.
if let Some(rect) = atom_response.rect(swatch_id) {
if self.visible {
ui.painter()
.circle_filled(rect.center(), 4.0, display_color);
} else {
// Neutral gray outline when hidden (inset by half stroke width to match filled size).
let stroke_color = ui.tokens().text_subdued;
ui.painter()
.circle_stroke(rect.center(), 3.5, Stroke::new(1.0, stroke_color));
}
}
atom_response.response
}
}