gpui_ui_kit/
menu.rs

1//! Menu components - MenuItem, Menu, MenuBar, and ContextMenu
2//!
3//! Provides a complete menu system for application navigation and context menus.
4
5use gpui::prelude::*;
6use gpui::*;
7
8/// Theme colors for menu styling
9#[derive(Debug, Clone)]
10pub struct MenuTheme {
11    /// Menu background color
12    pub background: Rgba,
13    /// Menu border color
14    pub border: Rgba,
15    /// Separator color
16    pub separator: Rgba,
17    /// Normal item text color
18    pub text: Rgba,
19    /// Hovered item text color
20    pub text_hover: Rgba,
21    /// Disabled item text color
22    pub text_disabled: Rgba,
23    /// Shortcut text color
24    pub text_shortcut: Rgba,
25    /// Item hover background color
26    pub hover_bg: Rgba,
27    /// Danger item hover background (for destructive actions like Quit)
28    pub danger_hover_bg: Rgba,
29}
30
31impl Default for MenuTheme {
32    fn default() -> Self {
33        Self {
34            background: rgba(0x2a2a2aff),
35            border: rgba(0x444444ff),
36            separator: rgba(0x3a3a3aff),
37            text: rgba(0xccccccff),
38            text_hover: rgba(0xffffffff),
39            text_disabled: rgba(0x666666ff),
40            text_shortcut: rgba(0x777777ff),
41            hover_bg: rgba(0x3a3a3aff),
42            danger_hover_bg: rgba(0xdc2626ff),
43        }
44    }
45}
46
47/// A single menu item
48#[derive(Clone)]
49pub struct MenuItem {
50    id: SharedString,
51    label: SharedString,
52    shortcut: Option<SharedString>,
53    icon: Option<SharedString>,
54    disabled: bool,
55    is_separator: bool,
56    is_checkbox: bool,
57    checked: bool,
58    is_danger: bool,
59    children: Vec<MenuItem>,
60}
61
62impl MenuItem {
63    /// Create a new menu item
64    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
65        Self {
66            id: id.into(),
67            label: label.into(),
68            shortcut: None,
69            icon: None,
70            disabled: false,
71            is_separator: false,
72            is_checkbox: false,
73            checked: false,
74            is_danger: false,
75            children: Vec::new(),
76        }
77    }
78
79    /// Create a separator item
80    pub fn separator() -> Self {
81        Self {
82            id: "separator".into(),
83            label: "".into(),
84            shortcut: None,
85            icon: None,
86            disabled: true,
87            is_separator: true,
88            is_checkbox: false,
89            checked: false,
90            is_danger: false,
91            children: Vec::new(),
92        }
93    }
94
95    /// Create a checkbox menu item
96    pub fn checkbox(
97        id: impl Into<SharedString>,
98        label: impl Into<SharedString>,
99        checked: bool,
100    ) -> Self {
101        Self {
102            id: id.into(),
103            label: label.into(),
104            shortcut: None,
105            icon: None,
106            disabled: false,
107            is_separator: false,
108            is_checkbox: true,
109            checked,
110            is_danger: false,
111            children: Vec::new(),
112        }
113    }
114
115    /// Add a keyboard shortcut display
116    pub fn with_shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
117        self.shortcut = Some(shortcut.into());
118        self
119    }
120
121    /// Add an icon
122    pub fn with_icon(mut self, icon: impl Into<SharedString>) -> Self {
123        self.icon = Some(icon.into());
124        self
125    }
126
127    /// Disable the menu item
128    pub fn disabled(mut self, disabled: bool) -> Self {
129        self.disabled = disabled;
130        self
131    }
132
133    /// Add submenu items
134    pub fn with_children(mut self, children: Vec<MenuItem>) -> Self {
135        self.children = children;
136        self
137    }
138
139    /// Get the item ID
140    pub fn id(&self) -> &SharedString {
141        &self.id
142    }
143
144    /// Check if this is a separator
145    pub fn is_separator(&self) -> bool {
146        self.is_separator
147    }
148
149    /// Mark as a danger/destructive action (e.g., Quit, Delete)
150    pub fn danger(mut self) -> Self {
151        self.is_danger = true;
152        self
153    }
154
155    /// Check if this is a danger item
156    pub fn is_danger(&self) -> bool {
157        self.is_danger
158    }
159}
160
161/// A dropdown menu containing menu items
162pub struct Menu {
163    items: Vec<MenuItem>,
164    min_width: Pixels,
165    theme: Option<MenuTheme>,
166    on_select: Option<Box<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>>,
167}
168
169impl Menu {
170    /// Create a new menu with items
171    pub fn new(items: Vec<MenuItem>) -> Self {
172        Self {
173            items,
174            min_width: px(180.0),
175            theme: None,
176            on_select: None,
177        }
178    }
179
180    /// Set minimum width
181    pub fn min_width(mut self, width: Pixels) -> Self {
182        self.min_width = width;
183        self
184    }
185
186    /// Set theme
187    pub fn theme(mut self, theme: MenuTheme) -> Self {
188        self.theme = Some(theme);
189        self
190    }
191
192    /// Set the selection handler
193    pub fn on_select(
194        mut self,
195        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
196    ) -> Self {
197        self.on_select = Some(Box::new(handler));
198        self
199    }
200
201    /// Build into element
202    pub fn build(self) -> Stateful<Div> {
203        let min_width = self.min_width;
204        let default_theme = MenuTheme::default();
205        let theme = self.theme.as_ref().unwrap_or(&default_theme);
206
207        let mut menu = div()
208            .id("menu-container")
209            .min_w(min_width)
210            .max_h(px(400.0))
211            .bg(theme.background)
212            .border_1()
213            .border_color(theme.border)
214            .rounded(px(4.0))
215            .shadow_lg()
216            .py_1()
217            .overflow_y_scroll();
218
219        for item in self.items {
220            if item.is_separator {
221                menu = menu.child(div().my_1().h(px(1.0)).bg(theme.separator).mx_2());
222            } else {
223                let item_id = item.id.clone();
224                let label = item.label.clone();
225                let shortcut = item.shortcut.clone();
226                let icon = item.icon.clone();
227                let disabled = item.disabled;
228                let is_checkbox = item.is_checkbox;
229                let checked = item.checked;
230                let is_danger = item.is_danger;
231
232                let on_select: Option<*const dyn Fn(&SharedString, &mut Window, &mut App)> =
233                    self.on_select.as_ref().map(|f| f.as_ref() as *const _);
234
235                let mut row = div()
236                    .id(SharedString::from(format!("menu-item-{}", item_id)))
237                    .px_3()
238                    .py(px(6.0))
239                    .mx_1()
240                    .rounded(px(3.0))
241                    .flex()
242                    .items_center()
243                    .gap_2()
244                    .text_sm();
245
246                if disabled {
247                    row = row.text_color(theme.text_disabled).cursor_not_allowed();
248                } else {
249                    let text_color = theme.text;
250                    let text_hover = theme.text_hover;
251                    let hover_bg = if is_danger {
252                        theme.danger_hover_bg
253                    } else {
254                        theme.hover_bg
255                    };
256
257                    row = row
258                        .text_color(text_color)
259                        .cursor_pointer()
260                        .hover(move |s| s.bg(hover_bg).text_color(text_hover));
261
262                    if let Some(handler_ptr) = on_select {
263                        let id = item_id.clone();
264                        row =
265                            row.on_mouse_up(MouseButton::Left, move |_event, window, cx| unsafe {
266                                (*handler_ptr)(&id, window, cx);
267                            });
268                    }
269                }
270
271                // Checkbox indicator
272                if is_checkbox {
273                    row = row.child(div().w(px(16.0)).text_xs().child(if checked {
274                        "✓"
275                    } else {
276                        " "
277                    }));
278                }
279
280                // Icon
281                if let Some(icon) = icon {
282                    row = row.child(div().w(px(16.0)).child(icon));
283                }
284
285                // Label (flex-1 to push shortcut to right)
286                row = row.child(div().flex_1().child(label));
287
288                // Shortcut
289                if let Some(shortcut) = shortcut {
290                    let shortcut_color = theme.text_shortcut;
291                    row = row.child(div().text_xs().text_color(shortcut_color).child(shortcut));
292                }
293
294                menu = menu.child(row);
295            }
296        }
297
298        menu
299    }
300}
301
302impl IntoElement for Menu {
303    type Element = Stateful<Div>;
304
305    fn into_element(self) -> Self::Element {
306        self.build()
307    }
308}
309
310/// A menu bar item (top-level menu)
311pub struct MenuBarItem {
312    id: SharedString,
313    label: SharedString,
314    items: Vec<MenuItem>,
315}
316
317impl MenuBarItem {
318    /// Create a new menu bar item
319    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
320        Self {
321            id: id.into(),
322            label: label.into(),
323            items: Vec::new(),
324        }
325    }
326
327    /// Set the dropdown items
328    pub fn with_items(mut self, items: Vec<MenuItem>) -> Self {
329        self.items = items;
330        self
331    }
332
333    /// Get the menu ID
334    pub fn id(&self) -> &SharedString {
335        &self.id
336    }
337
338    /// Get the menu label
339    pub fn label(&self) -> &SharedString {
340        &self.label
341    }
342
343    /// Get the menu items
344    pub fn items(&self) -> &[MenuItem] {
345        &self.items
346    }
347}
348
349/// A horizontal menu bar
350pub struct MenuBar {
351    items: Vec<MenuBarItem>,
352    active_menu: Option<SharedString>,
353    on_select: Option<Box<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>>,
354    on_menu_toggle: Option<Box<dyn Fn(Option<&SharedString>, &mut Window, &mut App) + 'static>>,
355}
356
357impl MenuBar {
358    /// Create a new menu bar
359    pub fn new(items: Vec<MenuBarItem>) -> Self {
360        Self {
361            items,
362            active_menu: None,
363            on_select: None,
364            on_menu_toggle: None,
365        }
366    }
367
368    /// Set the currently active (open) menu
369    pub fn active_menu(mut self, id: Option<SharedString>) -> Self {
370        self.active_menu = id;
371        self
372    }
373
374    /// Set the item selection handler
375    pub fn on_select(
376        mut self,
377        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
378    ) -> Self {
379        self.on_select = Some(Box::new(handler));
380        self
381    }
382
383    /// Set the menu toggle handler
384    pub fn on_menu_toggle(
385        mut self,
386        handler: impl Fn(Option<&SharedString>, &mut Window, &mut App) + 'static,
387    ) -> Self {
388        self.on_menu_toggle = Some(Box::new(handler));
389        self
390    }
391
392    /// Get menu bar items (for external rendering with custom handlers)
393    pub fn items(&self) -> &[MenuBarItem] {
394        &self.items
395    }
396
397    /// Get active menu ID
398    pub fn get_active_menu(&self) -> Option<&SharedString> {
399        self.active_menu.as_ref()
400    }
401
402    /// Build into element
403    pub fn build(self) -> Div {
404        let mut bar = div().flex().items_center().gap_1();
405
406        for item in &self.items {
407            let is_open = self.active_menu.as_ref() == Some(&item.id);
408            let menu_id = item.id.clone();
409            let label = item.label.clone();
410            let on_toggle: Option<*const dyn Fn(Option<&SharedString>, &mut Window, &mut App)> =
411                self.on_menu_toggle.as_ref().map(|f| f.as_ref() as *const _);
412
413            let mut button = div()
414                .id(SharedString::from(format!("menubar-{}", menu_id)))
415                .px_3()
416                .py_1()
417                .rounded(px(3.0))
418                .text_sm()
419                .cursor_pointer();
420
421            if is_open {
422                button = button
423                    .bg(rgb(0x3a3a3a))
424                    .font_weight(FontWeight::BOLD)
425                    .text_color(rgb(0xffffff));
426            } else {
427                button = button
428                    .text_color(rgb(0xcccccc))
429                    .hover(|s| s.bg(rgb(0x333333)));
430            }
431
432            if let Some(handler_ptr) = on_toggle {
433                let id = menu_id.clone();
434                let currently_open = is_open;
435                button = button.on_mouse_up(MouseButton::Left, move |_event, window, cx| unsafe {
436                    if currently_open {
437                        (*handler_ptr)(None, window, cx);
438                    } else {
439                        (*handler_ptr)(Some(&id), window, cx);
440                    }
441                });
442            }
443
444            button = button.child(label);
445            bar = bar.child(button);
446        }
447
448        bar
449    }
450}
451
452impl IntoElement for MenuBar {
453    type Element = Div;
454
455    fn into_element(self) -> Self::Element {
456        self.build()
457    }
458}
459
460/// Helper to build a single menu bar button without handlers
461/// Use this when you need to add cx.listener() handlers
462pub fn menu_bar_button(
463    id: impl Into<SharedString>,
464    label: impl Into<SharedString>,
465    is_open: bool,
466) -> Stateful<Div> {
467    let id = id.into();
468    let label = label.into();
469
470    let mut button = div()
471        .id(SharedString::from(format!("menubar-{}", id)))
472        .px_3()
473        .py_1()
474        .rounded(px(3.0))
475        .text_sm()
476        .cursor_pointer();
477
478    if is_open {
479        button = button
480            .bg(rgb(0x3a3a3a))
481            .font_weight(FontWeight::BOLD)
482            .text_color(rgb(0xffffff));
483    } else {
484        button = button
485            .text_color(rgb(0xcccccc))
486            .hover(|s| s.bg(rgb(0x333333)));
487    }
488
489    button.child(label)
490}