Skip to main content

elegance/
menu_bar.rs

1//! Top-of-window menu bar — a horizontal strip of click-to-open menus.
2//!
3//! [`MenuBar`] paints a desktop-style menu strip with an optional brand on
4//! the left, a row of menu triggers (File, Edit, View, …), and an optional
5//! status slot on the right. Clicking a trigger opens its dropdown; once
6//! one menu is open, hovering a sibling trigger switches to it. That
7//! "menu mode" hover-switching matches how native menubars feel.
8//!
9//! ```no_run
10//! # use elegance::{MenuBar, MenuItem};
11//! # egui::__run_test_ui(|ui| {
12//! MenuBar::new("app_menubar")
13//!     .brand("Elegance")
14//!     .status("main \u{00b7} up to date")
15//!     .show(ui, |bar| {
16//!         bar.menu("File", |ui| {
17//!             ui.add(MenuItem::new("New").shortcut("\u{2318}N"));
18//!             ui.add(MenuItem::new("Open\u{2026}").shortcut("\u{2318}O"));
19//!             ui.separator();
20//!             ui.add(MenuItem::new("Save").shortcut("\u{2318}S"));
21//!         });
22//!         bar.menu("Edit", |ui| {
23//!             ui.add(MenuItem::new("Undo").shortcut("\u{2318}Z"));
24//!         });
25//!     });
26//! # });
27//! ```
28//!
29//! Dropdowns close on outside-click, `Esc`, or clicking an item.
30//!
31//! For a single click-to-open menu attached to an arbitrary trigger button,
32//! use [`Menu`](crate::Menu) directly.
33
34use 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/// A horizontal desktop-style menu bar with click-to-open dropdowns.
57///
58/// See the module-level docs for an example.
59#[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    /// Create a new menu bar keyed by `id_salt`. The salt scopes per-menu
69    /// open state in egui memory and must be stable across frames.
70    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    /// Show a brand label on the left, preceded by a small accent square.
79    /// Use this for the application name.
80    #[inline]
81    pub fn brand(mut self, text: impl Into<WidgetText>) -> Self {
82        self.brand = Some(text.into());
83        self
84    }
85
86    /// Show a muted status line on the right (e.g. `"main · up to date"`).
87    #[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    /// Show a status line preceded by a coloured dot, useful for indicating
97    /// connection or run state (green for healthy, amber for running, red
98    /// for failing).
99    #[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    /// Render the menu bar. The closure receives a [`MenuBarUi`] used to
109    /// declare each menu's trigger label and dropdown body.
110    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        // The strip sits between the page background and the elevated card
115        // tone — close to body bg on dark themes, close to card on light
116        // themes. Mixing keeps the same visual relationship across all four
117        // built-in palettes.
118        let menubar_fill = mix(p.bg, p.card, 0.45);
119
120        // Read the previous frame's snapshot: which menus existed, where
121        // their triggers were, and whether any was open.
122        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        // Hover arbitrator. If a sibling trigger is being hovered while
129        // another menu of ours is currently open, close that other menu
130        // *before* any popup renders this frame. The new menu's normal
131        // hover-switch logic then opens itself in its own paint, and only
132        // one popup ends up visible. Without this step, the previously
133        // open menu would render its area for one extra frame because its
134        // `Popup::show` runs (and renders) before the new menu's call to
135        // `open_id` overwrites the memory slot.
136        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        // Bottom border separates the strip from the body content below.
203        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/// Per-frame state stored in [`egui::Memory`] so the next frame can know
217/// where each trigger sat and whether any of our menus was open.
218#[derive(Clone, Default, Debug)]
219struct MenuBarFrameState {
220    triggers: Vec<(Id, Rect)>,
221    any_open: bool,
222}
223
224/// The handle passed to a [`MenuBar::show`] closure for declaring menu
225/// triggers. Each call to [`MenuBarUi::menu`] paints one trigger and its
226/// dropdown.
227pub 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    /// Paint a single menu trigger with `label` and attach a dropdown
249    /// populated by `body`. Clicking an item inside the dropdown dismisses
250    /// the menu — the standard pattern for action-style menus (File / Edit
251    /// / etc.). For settings-style menus that should stay open while the
252    /// user toggles items, use [`MenuBarUi::menu_keep_open`].
253    ///
254    /// Returns `Some` with the body closure's return value while the
255    /// dropdown is open, `None` while it's closed.
256    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    /// Like [`MenuBarUi::menu`], but the dropdown stays open while the
265    /// user clicks items inside it. Useful for menus full of toggles
266    /// (checkboxes, radio groups) where the user expects to see the state
267    /// change without the menu vanishing. The menu still closes on click
268    /// outside, on `Esc`, or when the user clicks the trigger again.
269    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        // Decide what to do this frame.
306        //   1. Click: toggle this menu open/closed.
307        //   2. Hover while another menu of ours is already open: switch
308        //      to this one. `Bool(true)` opens this id and closes others
309        //      (egui memory only tracks one open popup at a time).
310        //   3. Otherwise: leave the state as-is.
311        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        // Dropdown panel. Top-left corner is square so the panel reads
347        // visually flush with the trigger above it.
348        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    // Layout is right-to-left here, so allocations come from the right edge
403    // inward — paint text first, then the dot to the left of it.
404    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}