//! In-window menus: a menu bar and right-click context menus, built with //! gpui's `anchored` + `deferred` popups (no platform menus). //! //! The pure data model lives in [`model`]; the views here render it. Keyboard //! navigation (up/down/enter/escape) works while the popup is focused; items //! with submenus open them on hover to the right of the parent menu. //! Activating an item emits [`MenuBarEvent::Triggered`] / //! [`ContextMenuEvent::Triggered`] as a request. pub mod model; use gpui::{ Anchor, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, KeyDownEvent, MouseButton, MouseUpEvent, Pixels, Point, Render, SharedString, Window, anchored, colors::DefaultColors, deferred, div, point, prelude::*, px, }; pub use model::{Menu, MenuItem}; /// The height of one menu row, used for submenu positioning estimates. const ROW_HEIGHT: f32 = 26.0; /// A fully transparent color (for un-hovered rows). fn transparent() -> gpui::Rgba { gpui::Rgba { r: 0.0, g: 0.0, b: 0.0, a: 0.0, } } /// A request emitted by a menu bar. #[derive(Debug, Clone, PartialEq)] pub enum MenuBarEvent { /// An item was activated. Triggered { /// The menu bar's stable id. control: usize, /// The item's id (from [`MenuItem::id`]). item: usize, /// The item's label. label: SharedString, }, /// A menu was opened. MenuOpened { /// The menu bar's stable id. control: usize, /// The menu index. index: usize, }, /// The open menu was closed. MenuClosed { /// The menu bar's stable id. control: usize, }, } /// A request emitted by a context menu. #[derive(Debug, Clone, PartialEq)] pub struct ContextMenuEvent { /// The item's id (from [`MenuItem::id`]). pub item: usize, /// The item's label. pub label: SharedString, } /// A titled menu in a menu bar. #[derive(Debug, Clone, PartialEq)] pub struct MenuBarEntry { /// The title shown in the bar. pub title: SharedString, /// The menu opened by the title. pub menu: Menu, } impl MenuBarEntry { /// Create an entry. pub fn new(title: impl Into, menu: Menu) -> Self { Self { title: title.into(), menu, } } } /// A horizontal menu bar with drop-down menus. pub struct MenuBar { control: usize, entries: Vec, focus_handle: FocusHandle, open: Option, popup_position: Point, was_open_at_down: bool, hovered: Option, submenu: Option, } impl MenuBar { /// Create a menu bar. pub fn new( control: usize, entries: Vec, _window: &mut Window, cx: &mut Context, ) -> Self { Self { control, entries, focus_handle: cx.focus_handle(), open: None, popup_position: Point::default(), was_open_at_down: false, hovered: None, submenu: None, } } /// Whether any menu is open. pub fn is_open(&self) -> bool { self.open.is_some() } /// Sets the checked state of the menu item with `id` across all entries /// (searching submenus recursively), so a host can toggle a checkmark at /// runtime without rebuilding the [`MenuBar`]. /// /// The checkmark appears on the next repaint (the renderer reads /// `checked` per frame); pair with a `cx.notify()` after the call. /// Returns whether an item with that id was found. pub fn set_item_checked(&mut self, id: usize, checked: bool) -> bool { let mut found = false; for entry in &mut self.entries { found |= entry.menu.set_item_checked(id, checked); } found } fn open_menu(&mut self, index: usize, position: Point, cx: &mut Context) { if self.open != Some(index) { self.open = Some(index); self.popup_position = position; self.hovered = None; self.submenu = None; cx.emit(MenuBarEvent::MenuOpened { control: self.control, index, }); cx.notify(); } } fn close_menu(&mut self, cx: &mut Context) { if self.open.take().is_some() { self.hovered = None; self.submenu = None; cx.emit(MenuBarEvent::MenuClosed { control: self.control, }); cx.notify(); } } fn trigger(&mut self, item: usize, cx: &mut Context) { let label = self .open .as_ref() .and_then(|index| self.entries.get(*index)) .and_then(|entry| entry.menu.items.iter().find(|i| i.id == item)) .map(|i| i.label.clone()) .or_else(|| { self.entries .iter() .flat_map(|e| &e.menu.items) .find(|i| i.id == item) .map(|i| i.label.clone()) }) .unwrap_or_default(); cx.emit(MenuBarEvent::Triggered { control: self.control, item, label, }); self.close_menu(cx); } fn navigate(&mut self, delta: i32, cx: &mut Context) { if let Some(index) = self.open { let menu = &self.entries[index].menu; if let Some(next) = menu.navigate(self.hovered, delta) { self.hovered = Some(next); self.submenu = None; cx.notify(); } } } } impl EventEmitter for MenuBar {} impl Focusable for MenuBar { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for MenuBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let mut bar = div() .id(ElementId::named_usize("gpui-widgets-menubar", self.control)) .flex() .items_center() .px_2() .gap_1() .bg(colors.container); for (index, entry) in self.entries.clone().into_iter().enumerate() { let is_open = self.open == Some(index); bar = bar.child( div() .id(ElementId::named_usize( format!("gpui-widgets-menu-title-{}", self.control), index, )) .px_2() .py_1() .rounded_md() .bg(if is_open { colors.selected } else { transparent() }) .text_color(if is_open { colors.selected_text } else { colors.text }) .cursor_pointer() .on_mouse_down( MouseButton::Left, cx.listener(|this, _event: &gpui::MouseDownEvent, _window, _cx| { this.was_open_at_down = this.open.is_some(); }), ) .on_click(cx.listener( move |this, event: &ClickEvent, _window, cx| { if this.was_open_at_down { this.close_menu(cx); } else { this.open_menu(index, event.position(), cx); } cx.stop_propagation(); }, )) .child(entry.title), ); } // The open menu popup. if let Some(open_index) = self.open { let entry = self.entries[open_index].clone(); let hovered = self.hovered; let menu_popup = menu_popup_element( self.control, &entry.menu, hovered, &colors, cx.listener(|this, item: &MenuClicked, _window, cx| { this.trigger(item.id, cx); }), cx.listener(|this, item: &MenuHovered, _window, cx| { if item.submenu { this.submenu = Some(item.index); cx.notify(); } }), ) .track_focus(&self.focus_handle) .on_mouse_up_out( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { this.close_menu(cx); }), ) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { match event.keystroke.key.as_str() { "up" => this.navigate(-1, cx), "down" => this.navigate(1, cx), "enter" | "space" => { if let Some(hovered) = this.hovered { if let Some(item) = entry_at(this, hovered) { if item.enabled && item.submenu.is_none() { this.trigger(item.id, cx); } } } } "escape" | "left" => this.close_menu(cx), _ => {} } })); bar = bar.child( deferred( anchored() .position(self.popup_position) .anchor(Anchor::TopLeft) .offset(point(px(0.0), px(ROW_HEIGHT))) .snap_to_window_with_margin(px(8.0)) .child(menu_popup), ) .with_priority(1), ); // A hovered item's submenu, anchored to the right of the popup. if let Some(hovered) = self.hovered && let Some(item) = self.entries[open_index].menu.items.get(hovered) && let Some(submenu) = item.submenu.clone() { let sub_hovered = self.submenu.and_then(|_| None); let sub_popup = menu_popup_element( self.control + 1000, &submenu, sub_hovered, &colors, cx.listener(|this, clicked: &MenuClicked, _window, cx| { this.trigger(clicked.id, cx); }), cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}), ); let width = f32::from(menu_width_estimate()); bar = bar.child( deferred( anchored() .position(self.popup_position) .anchor(Anchor::TopLeft) .offset(point(px(width + 2.0), px(ROW_HEIGHT))) .snap_to_window_with_margin(px(8.0)) .child(sub_popup), ) .with_priority(2), ); } // Focus the popup so keyboard navigation works. window.focus(&self.focus_handle, cx); } bar } } /// A right-click context menu. pub struct ContextMenu { focus_handle: FocusHandle, open: Option, } struct ContextMenuState { position: Point, menu: Menu, hovered: Option, } impl ContextMenu { /// Create a context menu (hidden until [`Self::show`]). pub fn new(_control: usize, _window: &mut Window, cx: &mut Context) -> Self { Self { focus_handle: cx.focus_handle(), open: None, } } /// Whether the menu is visible. pub fn is_open(&self) -> bool { self.open.is_some() } /// Show the menu at `position` (window coordinates). pub fn show(&mut self, position: Point, menu: Menu, cx: &mut Context) { self.open = Some(ContextMenuState { position, menu, hovered: None, }); cx.notify(); } /// Hide the menu. pub fn hide(&mut self, cx: &mut Context) { if self.open.take().is_some() { cx.notify(); } } } impl EventEmitter for ContextMenu {} impl Focusable for ContextMenu { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for ContextMenu { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.default_colors().clone(); let mut root = div(); if let Some(state) = self.open.take() { let menu = state.menu.clone(); let hovered = state.hovered; let position = state.position; let popup = menu_popup_element( 0, &menu, hovered, &colors, cx.listener(|this, clicked: &MenuClicked, _window, cx| { cx.emit(ContextMenuEvent { item: clicked.id, label: clicked.label.clone(), }); this.hide(cx); }), cx.listener(|_this, _item: &MenuHovered, _window, _cx| {}), ) .track_focus(&self.focus_handle) .on_mouse_up_out( MouseButton::Left, cx.listener(|this, _event: &MouseUpEvent, _window, cx| { this.hide(cx); }), ) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _window, cx| { match event.keystroke.key.as_str() { "up" | "down" => { if let Some(state) = this.open.as_mut() { let delta = if event.keystroke.key == "up" { -1 } else { 1 }; if let Some(next) = state.menu.navigate(state.hovered, delta) { state.hovered = Some(next); cx.notify(); } } } "enter" | "space" => { if let Some(state) = this.open.as_ref() && let Some(hovered) = state.hovered && let Some(item) = state.menu.items.get(hovered) && item.enabled { cx.emit(ContextMenuEvent { item: item.id, label: item.label.clone(), }); this.hide(cx); } } "escape" => this.hide(cx), _ => {} } })); root = root.child( deferred(anchored().position(position).child(popup)).with_priority(1), ); window.focus(&self.focus_handle, cx); self.open = Some(ContextMenuState { position, menu, hovered, }); } root } } /// Marker event types passed to the shared popup builder. struct MenuClicked { id: usize, label: SharedString, } struct MenuHovered { index: usize, submenu: bool, } /// Build a menu popup list. `on_click` receives the clicked item, `on_hover` /// receives hovered-item info (used to open submenus). fn menu_popup_element( control: usize, menu: &Menu, hovered: Option, colors: &gpui::colors::Colors, on_click: impl Fn(&MenuClicked, &mut Window, &mut App) + 'static, on_hover: impl Fn(&MenuHovered, &mut Window, &mut App) + 'static, ) -> gpui::Stateful { use std::sync::Arc; let on_click = Arc::new(on_click); let on_hover = Arc::new(on_hover); let mut column = div() .id(ElementId::named_usize("gpui-widgets-menu-popup", control)) .debug_selector(|| "menu-popup".into()) .min_w(px(180.0)) .rounded_md() .border_1() .border_color(colors.border) .bg(colors.container) .py_1() .flex() .flex_col(); for (index, item) in menu.items.iter().enumerate() { let id = item.id; let label = item.label.clone(); let shortcut = item.shortcut.clone(); let checked = item.checked; let enabled = item.enabled; let has_submenu = item.submenu.is_some(); let is_hovered = hovered == Some(index); let is_separator = Menu::is_separator(item); if is_separator { column = column.child(div().h(px(1.0)).my_1().bg(colors.separator)); continue; } let row = div() .id(ElementId::named_usize( format!("gpui-widgets-menu-item-{control}"), id, )) .px_2() .h(px(ROW_HEIGHT)) .flex() .items_center() .gap_2() .bg(if is_hovered { colors.selected } else { transparent() }) .text_color(if enabled { colors.text } else { colors.disabled }) .cursor_pointer() .child( div() .w(px(16.0)) .child(if checked == Some(true) { "✓" } else { "" }), ) .child(div().flex_1().child(label.clone())) .child(if has_submenu { "›" } else { "" }) .child( div() .text_color(colors.disabled) .child(shortcut.unwrap_or_default()), ); let row = if enabled { let on_click = on_click.clone(); let on_hover = on_hover.clone(); row.on_click(move |_event: &ClickEvent, window, cx| { on_click( &MenuClicked { id, label: label.clone(), }, window, cx, ); }) .on_hover(move |hovered: &bool, window, cx| { if *hovered { on_hover( &MenuHovered { index, submenu: has_submenu, }, window, cx, ); } }) } else { row }; column = column.child(row); } column } /// A rough menu width estimate for submenu placement (matches `min_w`). fn menu_width_estimate() -> Pixels { px(180.0) } /// Find the menu item at a raw index in the currently open menu. fn entry_at(bar: &MenuBar, index: usize) -> Option<&MenuItem> { bar.entries .get(bar.open?) .and_then(|entry| entry.menu.items.get(index)) } #[cfg(test)] mod tests { use super::*; use gpui::{Entity, Modifiers, TestAppContext, VisualTestContext, point, px, size}; #[test] fn menu_bar_open_close_round_trip() { // Pure state checks are in model tests; here we just ensure the // model types are wired through the view API. let menu = Menu::new(vec![MenuItem::new(1, "Save").with_shortcut("⌘S")]); let entry = MenuBarEntry::new("File", menu); assert_eq!(entry.title, "File"); assert_eq!(entry.menu.items[0].id, 1); } #[test] fn context_menu_event_carries_item() { let event = ContextMenuEvent { item: 7, label: "Paste".into(), }; assert_eq!(event.item, 7); assert_eq!(event.label, "Paste"); } // --- interaction tests for the menu views --- fn demo_entries() -> Vec { vec![MenuBarEntry::new( "File", Menu::new(vec![ MenuItem::new(10, "Open…").with_shortcut("⌘O"), MenuItem::new(11, "Save").with_shortcut("⌘S"), MenuItem::new(12, "Quit").separated(), ]), )] } struct Host { menu_bar: Entity, events: Vec, } impl Render for Host { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div().size_full().child(self.menu_bar.clone()) } } fn make_bar(cx: &mut TestAppContext) -> (&'static mut VisualTestContext, Entity) { cx.update(|cx| cx.init_colors()); let window = cx.open_window(size(px(400.0), px(120.0)), |window, cx| { let menu_bar = cx.new(|cx| MenuBar::new(1, demo_entries(), window, cx)); let host = Host { menu_bar, events: Vec::new(), }; cx.subscribe( &host.menu_bar, |host: &mut Host, _m: Entity, event: &MenuBarEvent, _cx: &mut Context| { host.events.push(event.clone()); }, ) .detach(); host }); cx.run_until_parked(); let host = window.root(cx).unwrap(); let cx = VisualTestContext::from_window(window.into(), cx).into_mut(); (cx, host) } #[gpui::test] async fn clicking_a_menu_title_opens_the_popup(cx: &mut TestAppContext) { let (cx, _host) = make_bar(cx); // Click the "File" title (top-left of the bar). cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none()); cx.run_until_parked(); cx.update(|window, cx| { window.draw(cx).clear(); }); let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered"); assert!(popup.size.height > px(60.0), "popup should list the items"); } #[gpui::test] async fn clicking_a_menu_item_emits_triggered(cx: &mut TestAppContext) { let (cx, host) = make_bar(cx); cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none()); cx.run_until_parked(); cx.update(|window, cx| { window.draw(cx).clear(); }); let popup = cx.debug_bounds("menu-popup").expect("menu popup rendered"); // The first item row is the first ~26px of the popup. cx.simulate_click( point(popup.left() + px(40.0), popup.top() + px(16.0)), Modifiers::none(), ); cx.run_until_parked(); let triggered = cx.read(|app| { host.read(app).events.iter().any(|e| { matches!(e, MenuBarEvent::Triggered { item: 10, .. }) }) }); assert!(triggered, "expected Triggered for the first item"); } #[gpui::test] async fn keyboard_navigation_triggers_the_hovered_item(cx: &mut TestAppContext) { let (cx, host) = make_bar(cx); cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none()); cx.run_until_parked(); cx.update(|window, cx| { window.draw(cx).clear(); }); // Down from no selection lands on the first item; the second down // moves to Save. Enter triggers it. cx.simulate_keystrokes("down"); cx.run_until_parked(); cx.simulate_keystrokes("down"); cx.run_until_parked(); cx.simulate_keystrokes("enter"); cx.run_until_parked(); let triggered = cx.read(|app| { host.read(app).events.iter().any(|e| { matches!(e, MenuBarEvent::Triggered { item: 11, .. }) }) }); assert!(triggered, "expected Triggered for the second item via keyboard"); } #[gpui::test] async fn escape_closes_the_menu(cx: &mut TestAppContext) { let (cx, host) = make_bar(cx); cx.simulate_click(point(px(20.0), px(10.0)), Modifiers::none()); cx.run_until_parked(); cx.update(|window, cx| { window.draw(cx).clear(); }); assert!(cx.debug_bounds("menu-popup").is_some()); cx.simulate_keystrokes("escape"); cx.run_until_parked(); cx.update(|window, cx| { window.draw(cx).clear(); }); assert!(cx.debug_bounds("menu-popup").is_none(), "menu should close on escape"); let closed = cx.read(|app| { host.read(app).events.iter().any(|e| matches!(e, MenuBarEvent::MenuClosed { .. })) }); assert!(closed); } #[gpui::test] async fn runtime_set_item_checked_flips_the_checkmark(cx: &mut TestAppContext) { let (cx, host) = make_bar(cx); // Toggle item 11 ("Save") at runtime, by id. let changed = cx.update(|_window, app| { let bar = host.read(app).menu_bar.clone(); bar.update(app, |bar, _cx| bar.set_item_checked(11, true)) }); assert!(changed, "item 11 exists and should be updated"); let checked = cx.update(|_window, app| { host.read(app).menu_bar.read(app).entries[0].menu.items[1].checked }); assert_eq!(checked, Some(true)); // Unknown ids are reported as not found and change nothing. let changed = cx.update(|_window, app| { let bar = host.read(app).menu_bar.clone(); bar.update(app, |bar, _cx| bar.set_item_checked(12345, true)) }); assert!(!changed); let checked = cx.update(|_window, app| { host.read(app).menu_bar.read(app).entries[0].menu.items[0].checked }); assert_eq!(checked, None); } }