Skip to main content

armas_basic/components/
menubar.rs

1//! Menubar Component (shadcn/ui style)
2//!
3//! A horizontal menu bar (File, Edit, View, Help) composing [`DropdownMenu`] components.
4//! Supports hover-to-switch between open menus.
5//!
6//! ```rust,no_run
7//! # use egui::Ui;
8//! # fn example(ui: &mut Ui) {
9//! use armas_basic::prelude::*;
10//!
11//! Menubar::new("app_menu").show(ui, |bar| {
12//!     bar.menu("File", |menu| {
13//!         menu.item("New");
14//!         menu.item("Open");
15//!         menu.separator();
16//!         menu.item("Exit");
17//!     });
18//!     bar.menu("Edit", |menu| {
19//!         menu.item("Undo").shortcut("⌘Z");
20//!         menu.item("Redo").shortcut("⇧⌘Z");
21//!     });
22//! });
23//! # }
24//! ```
25
26use super::content::ContentContext;
27use super::dropdown_menu::{DropdownMenu, MenuBuilder};
28use egui::{Id, Rect, Sense, Stroke, Ui};
29
30// Constants matching shadcn menubar
31const TRIGGER_PADDING_X: f32 = 12.0; // px-3
32const TRIGGER_PADDING_Y: f32 = 6.0; // py-1.5
33                                    // Trigger text size resolved from theme.typography.base at show-time
34const TRIGGER_RADIUS: f32 = 4.0; // rounded-sm
35const BAR_HEIGHT: f32 = 40.0; // h-10
36const BAR_RADIUS: f32 = 6.0; // rounded-md
37const BAR_PADDING: f32 = 4.0; // p-1
38
39/// Boxed closure for rendering custom menu trigger content.
40type TriggerRenderFn = Box<dyn Fn(&mut Ui, &ContentContext)>;
41
42/// Trigger content for a menu entry.
43enum MenuTrigger {
44    /// Text label trigger (existing behavior).
45    Text(String),
46    /// Custom content trigger with explicit width.
47    Custom { width: f32, render: TriggerRenderFn },
48}
49
50/// A menu entry definition (trigger + dropdown items)
51struct MenuEntry {
52    trigger: MenuTrigger,
53    items: Vec<super::dropdown_menu::MenuItemData>,
54}
55
56/// Builder for adding menus to a menubar.
57pub struct MenubarBuilder {
58    entries: Vec<MenuEntry>,
59}
60
61impl MenubarBuilder {
62    const fn new() -> Self {
63        Self {
64            entries: Vec::new(),
65        }
66    }
67
68    /// Add a menu with a text trigger label and dropdown content.
69    pub fn menu(&mut self, label: impl Into<String>, content: impl FnOnce(&mut MenuBuilder)) {
70        let mut builder = MenuBuilder::new();
71        content(&mut builder);
72        self.entries.push(MenuEntry {
73            trigger: MenuTrigger::Text(label.into()),
74            items: builder.into_items(),
75        });
76    }
77
78    /// Add a menu with custom trigger content and dropdown content.
79    ///
80    /// `trigger_width` specifies the width of the trigger area.
81    /// The `trigger` closure receives a `&mut Ui` and [`ContentContext`] and is called
82    /// every frame to render the trigger (use icons, icon+text, etc.).
83    ///
84    /// # Example
85    ///
86    /// ```ignore
87    /// bar.menu_ui(60.0, |ui, ctx| {
88    ///     // Render icon trigger using ctx.color
89    /// }, |menu| {
90    ///     menu.item("New");
91    ///     menu.item("Open");
92    /// });
93    /// ```
94    pub fn menu_ui(
95        &mut self,
96        trigger_width: f32,
97        trigger: impl Fn(&mut Ui, &ContentContext) + 'static,
98        content: impl FnOnce(&mut MenuBuilder),
99    ) {
100        let mut builder = MenuBuilder::new();
101        content(&mut builder);
102        self.entries.push(MenuEntry {
103            trigger: MenuTrigger::Custom {
104                width: trigger_width,
105                render: Box::new(trigger),
106            },
107            items: builder.into_items(),
108        });
109    }
110}
111
112/// Menubar — a horizontal menu bar composing [`DropdownMenu`] components.
113pub struct Menubar {
114    id: Id,
115}
116
117/// Response from a menubar.
118pub struct MenubarResponse {
119    /// The UI response for the bar itself.
120    pub response: egui::Response,
121}
122
123impl Menubar {
124    /// Create a new menubar with a unique ID.
125    pub fn new(id: impl Into<Id>) -> Self {
126        Self { id: id.into() }
127    }
128
129    /// Show the menubar.
130    pub fn show(self, ui: &mut Ui, menus: impl FnOnce(&mut MenubarBuilder)) -> MenubarResponse {
131        let theme = crate::ext::ArmasContextExt::armas_theme(ui.ctx());
132
133        // Build menu entries
134        let mut builder = MenubarBuilder::new();
135        menus(&mut builder);
136        let entries = builder.entries;
137
138        // Load persisted state
139        let state_id = self.id.with("menubar_active");
140        let mut active_menu: Option<usize> =
141            ui.ctx().data_mut(|d| d.get_temp(state_id).unwrap_or(None));
142
143        // Draw the bar background
144        let (bar_rect, bar_response) =
145            ui.allocate_exact_size(egui::vec2(ui.available_width(), BAR_HEIGHT), Sense::hover());
146
147        ui.painter()
148            .rect_filled(bar_rect, BAR_RADIUS, theme.background());
149        ui.painter().rect_stroke(
150            bar_rect,
151            BAR_RADIUS,
152            Stroke::new(1.0, theme.border()),
153            egui::epaint::StrokeKind::Inside,
154        );
155
156        // Draw trigger buttons and collect their rects
157        let mut trigger_rects: Vec<Rect> = Vec::new();
158        let mut x = bar_rect.left() + BAR_PADDING;
159        let trigger_text_size = theme.typography.base;
160        let trigger_y =
161            bar_rect.top() + (BAR_HEIGHT - TRIGGER_PADDING_Y * 2.0 - trigger_text_size) / 2.0;
162
163        for (i, entry) in entries.iter().enumerate() {
164            let trigger_height = trigger_text_size + TRIGGER_PADDING_Y * 2.0;
165
166            let trigger_width = match &entry.trigger {
167                MenuTrigger::Text(label) => {
168                    let galley = ui.painter().layout_no_wrap(
169                        label.clone(),
170                        egui::FontId::proportional(trigger_text_size),
171                        theme.foreground(),
172                    );
173                    galley.size().x + TRIGGER_PADDING_X * 2.0
174                }
175                MenuTrigger::Custom { width, .. } => *width,
176            };
177
178            let trigger_rect = Rect::from_min_size(
179                egui::pos2(x, trigger_y),
180                egui::vec2(trigger_width, trigger_height),
181            );
182
183            let trigger_response = ui.interact(trigger_rect, self.id.with(i), Sense::click());
184            let is_active = active_menu == Some(i);
185            let is_hovered = trigger_response.hovered();
186
187            // Background: active or hovered
188            if is_active || is_hovered {
189                ui.painter()
190                    .rect_filled(trigger_rect, TRIGGER_RADIUS, theme.accent());
191            }
192
193            // Render trigger content
194            let text_color = if is_active || is_hovered {
195                theme.accent_foreground()
196            } else {
197                theme.foreground()
198            };
199
200            match &entry.trigger {
201                MenuTrigger::Text(label) => {
202                    ui.painter().text(
203                        trigger_rect.center(),
204                        egui::Align2::CENTER_CENTER,
205                        label,
206                        egui::FontId::proportional(trigger_text_size),
207                        text_color,
208                    );
209                }
210                MenuTrigger::Custom { render, .. } => {
211                    let mut child_ui = ui.new_child(
212                        egui::UiBuilder::new()
213                            .max_rect(trigger_rect)
214                            .layout(egui::Layout::left_to_right(egui::Align::Center)),
215                    );
216                    child_ui.style_mut().visuals.override_text_color = Some(text_color);
217
218                    let ctx = ContentContext {
219                        color: text_color,
220                        font_size: trigger_text_size,
221                        is_active,
222                    };
223                    render(&mut child_ui, &ctx);
224                }
225            }
226
227            // Click to toggle
228            if trigger_response.clicked() {
229                if is_active {
230                    active_menu = None;
231                } else {
232                    active_menu = Some(i);
233                }
234            }
235
236            // Hover-to-switch: when a menu is already open and user hovers another trigger
237            if active_menu.is_some() && !is_active && is_hovered {
238                active_menu = Some(i);
239            }
240
241            trigger_rects.push(trigger_rect);
242            x += trigger_width + 2.0;
243        }
244
245        // Show the active dropdown menu
246        if let Some(active_idx) = active_menu {
247            if active_idx < entries.len() {
248                let anchor = trigger_rects[active_idx];
249                let mut dropdown =
250                    DropdownMenu::new(self.id.with(("dropdown", active_idx))).open(true);
251
252                let entry_items = &entries[active_idx].items;
253                let response = dropdown.show(ui.ctx(), anchor, |menu| {
254                    replay_items(menu, entry_items);
255                });
256
257                if response.selected.is_some() || response.clicked_outside {
258                    active_menu = None;
259                }
260            }
261        }
262
263        // Save state
264        ui.ctx().data_mut(|d| d.insert_temp(state_id, active_menu));
265
266        MenubarResponse {
267            response: bar_response,
268        }
269    }
270}
271
272/// Replay pre-built menu items into a `MenuBuilder`.
273fn replay_items(menu: &mut MenuBuilder, items: &[super::dropdown_menu::MenuItemData]) {
274    for item_data in items {
275        menu.push_item(item_data.clone());
276    }
277}