1use std::hash::Hash;
35
36use egui::{
37 emath::RectAlign, Align, Color32, CornerRadius, Frame, Id, Layout, Margin, Popup,
38 PopupCloseBehavior, Pos2, Rect, Sense, SetOpenCommand, Stroke, Ui, Vec2, WidgetInfo,
39 WidgetText, WidgetType,
40};
41
42use crate::theme::{mix, with_alpha, Accent, Theme};
43
44const STRIP_PAD_Y: f32 = 4.0;
45const STRIP_PAD_X: f32 = 6.0;
46const TRIGGER_PAD_X: f32 = 10.0;
47const TRIGGER_PAD_Y: f32 = 5.0;
48const BRAND_LOGO_SIZE: f32 = 14.0;
49
50#[derive(Debug, Clone)]
51struct StatusContent {
52 text: WidgetText,
53 dot: Option<Color32>,
54}
55
56#[derive(Debug, Clone)]
60#[must_use = "Call `.show(ui, |bar| ...)` to render the menu bar."]
61pub struct MenuBar {
62 id_salt: Id,
63 brand: Option<WidgetText>,
64 status: Option<StatusContent>,
65}
66
67impl MenuBar {
68 pub fn new(id_salt: impl Hash) -> Self {
71 Self {
72 id_salt: Id::new(("elegance::menu_bar", Id::new(id_salt))),
73 brand: None,
74 status: None,
75 }
76 }
77
78 #[inline]
81 pub fn brand(mut self, text: impl Into<WidgetText>) -> Self {
82 self.brand = Some(text.into());
83 self
84 }
85
86 #[inline]
88 pub fn status(mut self, text: impl Into<WidgetText>) -> Self {
89 self.status = Some(StatusContent {
90 text: text.into(),
91 dot: None,
92 });
93 self
94 }
95
96 #[inline]
100 pub fn status_with_dot(mut self, text: impl Into<WidgetText>, dot: Color32) -> Self {
101 self.status = Some(StatusContent {
102 text: text.into(),
103 dot: Some(dot),
104 });
105 self
106 }
107
108 pub fn show<R>(self, ui: &mut Ui, body: impl FnOnce(&mut MenuBarUi<'_>) -> R) -> R {
111 let theme = Theme::current(ui.ctx());
112 let p = &theme.palette;
113
114 let menubar_fill = mix(p.bg, p.card, 0.45);
119
120 let state_id = self.id_salt.with("__state");
123 let prev_state: MenuBarFrameState = ui
124 .ctx()
125 .data(|d| d.get_temp::<MenuBarFrameState>(state_id))
126 .unwrap_or_default();
127
128 if prev_state.any_open {
137 if let Some(pointer) = ui.ctx().pointer_hover_pos() {
138 let open_idx = prev_state
139 .triggers
140 .iter()
141 .position(|(id, _)| Popup::is_id_open(ui.ctx(), *id));
142 if let Some(open_idx) = open_idx {
143 let on_sibling = prev_state
144 .triggers
145 .iter()
146 .enumerate()
147 .any(|(i, (_, rect))| i != open_idx && rect.contains(pointer));
148 if on_sibling {
149 Popup::close_id(ui.ctx(), prev_state.triggers[open_idx].0);
150 }
151 }
152 }
153 }
154
155 let frame = Frame::new()
156 .fill(menubar_fill)
157 .inner_margin(Margin::symmetric(STRIP_PAD_X as i8, STRIP_PAD_Y as i8));
158
159 let outer = frame.show(ui, |ui| {
160 ui.horizontal(|ui| {
161 ui.spacing_mut().item_spacing.x = 0.0;
162 ui.set_min_height(theme.typography.body + TRIGGER_PAD_Y * 2.0);
163
164 if let Some(brand) = self.brand.as_ref() {
165 paint_brand(ui, &theme, brand.clone());
166 }
167
168 let mut bar = MenuBarUi {
169 ui,
170 base_id: self.id_salt,
171 next_idx: 0,
172 any_open_prev: prev_state.any_open,
173 any_open_now: false,
174 triggers: Vec::with_capacity(prev_state.triggers.len()),
175 };
176 let r = body(&mut bar);
177 let any_open_now = bar.any_open_now;
178 let triggers = std::mem::take(&mut bar.triggers);
179
180 if let Some(status) = self.status.as_ref() {
181 bar.ui
182 .with_layout(Layout::right_to_left(Align::Center), |ui| {
183 paint_status(ui, &theme, status);
184 });
185 }
186
187 bar.ui.ctx().data_mut(|d| {
188 d.insert_temp(
189 state_id,
190 MenuBarFrameState {
191 triggers,
192 any_open: any_open_now,
193 },
194 )
195 });
196
197 r
198 })
199 .inner
200 });
201
202 let strip_rect = outer.response.rect;
204 ui.painter().line_segment(
205 [
206 Pos2::new(strip_rect.min.x, strip_rect.max.y - 0.5),
207 Pos2::new(strip_rect.max.x, strip_rect.max.y - 0.5),
208 ],
209 Stroke::new(1.0, p.border),
210 );
211
212 outer.inner
213 }
214}
215
216#[derive(Clone, Default, Debug)]
219struct MenuBarFrameState {
220 triggers: Vec<(Id, Rect)>,
221 any_open: bool,
222}
223
224pub struct MenuBarUi<'u> {
228 ui: &'u mut Ui,
229 base_id: Id,
230 next_idx: usize,
231 any_open_prev: bool,
232 any_open_now: bool,
233 triggers: Vec<(Id, Rect)>,
234}
235
236impl<'u> std::fmt::Debug for MenuBarUi<'u> {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.debug_struct("MenuBarUi")
239 .field("base_id", &self.base_id)
240 .field("next_idx", &self.next_idx)
241 .field("any_open_prev", &self.any_open_prev)
242 .field("any_open_now", &self.any_open_now)
243 .finish()
244 }
245}
246
247impl<'u> MenuBarUi<'u> {
248 pub fn menu<R>(
257 &mut self,
258 label: impl Into<WidgetText>,
259 body: impl FnOnce(&mut Ui) -> R,
260 ) -> Option<R> {
261 self.menu_inner(label, PopupCloseBehavior::CloseOnClick, body)
262 }
263
264 pub fn menu_keep_open<R>(
270 &mut self,
271 label: impl Into<WidgetText>,
272 body: impl FnOnce(&mut Ui) -> R,
273 ) -> Option<R> {
274 self.menu_inner(label, PopupCloseBehavior::CloseOnClickOutside, body)
275 }
276
277 fn menu_inner<R>(
278 &mut self,
279 label: impl Into<WidgetText>,
280 close_behavior: PopupCloseBehavior,
281 body: impl FnOnce(&mut Ui) -> R,
282 ) -> Option<R> {
283 let label: WidgetText = label.into();
284 let theme = Theme::current(self.ui.ctx());
285 let p = &theme.palette;
286 let t = &theme.typography;
287
288 let idx = self.next_idx;
289 self.next_idx += 1;
290 let popup_id = self.base_id.with("__menu").with(idx);
291
292 let galley =
293 crate::theme::placeholder_galley(self.ui, label.text(), t.body, false, f32::INFINITY);
294 let trigger_size = Vec2::new(
295 galley.size().x + TRIGGER_PAD_X * 2.0,
296 galley.size().y + TRIGGER_PAD_Y * 2.0,
297 );
298 let (rect, response) = self.ui.allocate_exact_size(trigger_size, Sense::click());
299 self.triggers.push((popup_id, rect));
300
301 let was_open = Popup::is_id_open(self.ui.ctx(), popup_id);
302 let hovered = response.hovered();
303 let clicked = response.clicked();
304
305 let intent: Option<SetOpenCommand> = if clicked {
312 Some(SetOpenCommand::Bool(!was_open))
313 } else if self.any_open_prev && hovered && !was_open {
314 Some(SetOpenCommand::Bool(true))
315 } else {
316 None
317 };
318
319 let will_be_open = matches!(intent, Some(SetOpenCommand::Bool(true)))
320 || (was_open && !matches!(intent, Some(SetOpenCommand::Bool(false))));
321 self.any_open_now |= will_be_open;
322
323 if self.ui.is_rect_visible(rect) {
324 let bg = if will_be_open {
325 p.card
326 } else if hovered {
327 with_alpha(p.text, 14)
328 } else {
329 Color32::TRANSPARENT
330 };
331 if bg.a() > 0 {
332 self.ui.painter().rect_filled(rect, CornerRadius::ZERO, bg);
333 }
334 let text_color = if will_be_open || hovered {
335 p.text
336 } else {
337 p.text_muted
338 };
339 let pos = Pos2::new(
340 rect.min.x + TRIGGER_PAD_X,
341 rect.center().y - galley.size().y * 0.5,
342 );
343 self.ui.painter().galley(pos, galley, text_color);
344 }
345
346 let r = theme.card_radius as u8;
349 let frame = Frame::new()
350 .fill(p.card)
351 .stroke(Stroke::new(1.0, p.border))
352 .corner_radius(CornerRadius {
353 nw: 0,
354 ne: r,
355 sw: r,
356 se: r,
357 })
358 .inner_margin(Margin::same(4));
359
360 let label_text = label.text().to_string();
361 response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label_text));
362
363 let result = Popup::menu(&response)
364 .id(popup_id)
365 .open_memory(intent)
366 .align(RectAlign::BOTTOM_START)
367 .gap(0.0)
368 .frame(frame)
369 .close_behavior(close_behavior)
370 .show(|ui| {
371 ui.spacing_mut().item_spacing.y = 2.0;
372 body(ui)
373 });
374
375 result.map(|r| r.inner)
376 }
377}
378
379fn paint_brand(ui: &mut Ui, theme: &Theme, text: WidgetText) {
380 let p = &theme.palette;
381 let t = &theme.typography;
382
383 let logo_size = Vec2::splat(BRAND_LOGO_SIZE);
384 let (logo_rect, _) = ui.allocate_exact_size(logo_size, Sense::hover());
385 ui.painter()
386 .rect_filled(logo_rect, CornerRadius::same(3), p.accent_fill(Accent::Sky));
387 ui.add_space(8.0);
388
389 let galley = crate::theme::placeholder_galley(ui, text.text(), t.body, true, f32::INFINITY);
390 let label_size = Vec2::new(galley.size().x, galley.size().y + 4.0);
391 let (rect, _) = ui.allocate_exact_size(label_size, Sense::hover());
392 let pos = Pos2::new(rect.min.x, rect.center().y - galley.size().y * 0.5);
393 ui.painter().galley(pos, galley, p.text);
394
395 ui.add_space(14.0);
396}
397
398fn paint_status(ui: &mut Ui, theme: &Theme, status: &StatusContent) {
399 let p = &theme.palette;
400 let t = &theme.typography;
401
402 ui.add_space(4.0);
405 let galley =
406 crate::theme::placeholder_galley(ui, status.text.text(), t.small, false, f32::INFINITY);
407 let label_size = Vec2::new(galley.size().x, galley.size().y + 4.0);
408 let (rect, _) = ui.allocate_exact_size(label_size, Sense::hover());
409 let pos = Pos2::new(rect.min.x, rect.center().y - galley.size().y * 0.5);
410 ui.painter().galley(pos, galley, p.text_faint);
411
412 if let Some(color) = status.dot {
413 ui.add_space(6.0);
414 let (dot_rect, _) = ui.allocate_exact_size(Vec2::splat(7.0), Sense::hover());
415 ui.painter().circle_filled(dot_rect.center(), 3.5, color);
416 }
417}