use std::cell::RefCell; use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DispatchPhase, ElementId, Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement as _, Pixels, RenderOnce, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, px, relative, }; use gpui_native_icons::{IconRole, icon as icon_glyph}; use gpui_native_theme::ActiveTheme as _; use crate::common::v_flex; use crate::scroll_area::{ScrollArea, ScrollAxes}; /// Runs the item's action. The menu closes itself before the handler runs. pub type ItemHandler = Rc; /// Reports the next open state of a [`Menu`]: `true` to open, `false` to close. type ChangeHandler = Rc; type TriggerFn = Box AnyElement>; type ItemsFn = Box Vec>; /// One row of a [`Menu`]: an action, a checkable action, or a separator. #[derive(Clone)] pub struct MenuItem { kind: MenuItemKind, label: SharedString, icon: Option, shortcut: Option, handler: Option, disabled: bool, destructive: bool, checked: Option, } #[derive(Clone)] enum MenuItemKind { Action, Separator, } impl MenuItem { /// An action row with this label. pub fn new(label: L) -> Self where L: Into, { Self { kind: MenuItemKind::Action, label: label.into(), icon: None, shortcut: None, handler: None, disabled: false, destructive: false, checked: None, } } /// A divider between groups of actions. pub fn separator() -> Self { Self { kind: MenuItemKind::Separator, label: SharedString::default(), icon: None, shortcut: None, handler: None, disabled: false, destructive: false, checked: None, } } /// Runs this action when the item is activated. The menu closes first. pub fn handler(mut self, handler: F) -> Self where F: Fn(&mut Window, &mut App) + 'static, { self.handler = Some(Rc::new(handler)); self } /// Shows an icon before the label. pub fn icon(mut self, role: IconRole) -> Self { self.icon = Some(role); self } /// Shows a shortcut hint after the label. pub fn shortcut(mut self, shortcut: S) -> Self where S: Into, { self.shortcut = Some(shortcut.into()); self } /// Marks the item non-selectable and non-activatable. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; self } /// Paints the row in the danger role. pub fn destructive(mut self, destructive: bool) -> Self { self.destructive = destructive; self } /// Shows a check slot before the label. pub fn checked(mut self, checked: bool) -> Self { self.checked = Some(checked); self } /// Whether keyboard navigation and activation may land on this item. fn selectable(&self) -> bool { matches!(self.kind, MenuItemKind::Action) && !self.disabled } } /// Menu chrome styles. #[derive(Clone)] struct MenuChrome { surface: gpui::Hsla, border: gpui::Hsla, foreground: gpui::Hsla, muted_foreground: gpui::Hsla, primary: gpui::Hsla, primary_foreground: gpui::Hsla, destructive: gpui::Hsla, destructive_foreground: gpui::Hsla, radius_overlay: Pixels, radius_sm: Pixels, icon_size: Pixels, shadow: Vec, } /// A floating menu of actions anchored below its trigger. #[derive(IntoElement)] pub struct Menu { id: ElementId, open: bool, a11y_label: Option, on_change: Option, trigger: Option, items: Option, } impl Menu { /// Creates a closed menu. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), open: false, a11y_label: None, on_change: None, trigger: None, items: None, } } /// Sets the current open state. pub fn open(mut self, open: bool) -> Self { self.open = open; self } /// Reports the next open state, from dismissals, toggles, and activations alike. pub fn on_change(mut self, handler: F) -> Self where F: Fn(bool, &mut Window, &mut App) + 'static, { self.on_change = Some(Rc::new(handler)); self } /// Sets the element the panel anchors below. pub fn trigger(mut self, trigger: F) -> Self where F: Fn(&mut Window, &mut App) -> AnyElement + 'static, { self.trigger = Some(Box::new(trigger)); self } /// Sets the menu's items, built fresh each render. pub fn items(mut self, items: F) -> Self where F: Fn(&mut Window, &mut App) -> Vec + 'static, { self.items = Some(Box::new(items)); self } /// Overrides the accessibility label of the panel. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl RenderOnce for Menu { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (chrome, enter_duration) = { let theme = cx.theme(); ( MenuChrome { surface: theme.colors.surface, border: theme.colors.border, foreground: theme.colors.foreground, muted_foreground: theme.colors.muted_foreground, primary: theme.ramps.primary.base, primary_foreground: theme.colors.primary_foreground, destructive: theme.ramps.destructive.base, destructive_foreground: theme.colors.destructive_foreground, radius_overlay: theme.radius.overlay, radius_sm: theme.radius.sm, icon_size: theme.sizes.medium.icon_size, shadow: theme.elevations.popover.shadows(), }, theme.motion.overlay, ) }; let trigger_bounds: Rc>>> = Rc::new(RefCell::new(None)); let panel_bounds: Rc>>> = Rc::new(RefCell::new(None)); let selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize); let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open); let fallback_label = self .a11y_label .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); let mut wrapper = div() .relative() .child( canvas( { let cell = trigger_bounds.clone(); move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) }, { let on_change = self.on_change.clone(); let open = self.open; let cell = trigger_bounds.clone(); let was_open = was_open.clone(); move |_, _, window, cx| { let trigger_bounds = cell; if !open { was_open.update(cx, |was_open, _| *was_open = false); } window.on_mouse_event({ let on_change = on_change.clone(); move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != MouseButton::Left { return; } let on_trigger = trigger_bounds .borrow() .is_some_and(|bounds| bounds.contains(&event.position)); if !on_trigger { return; } if let Some(on_change) = &on_change { on_change(!open, window, cx); } cx.stop_propagation(); } }); } }, ) .absolute() .inset_0(), ) .when_some(self.trigger, |wrapper, trigger| { wrapper.child(trigger(window, cx)) }); if self.open { let on_change = self.on_change.clone(); let label = fallback_label.clone(); let items = match self.items { Some(build) => build(window, cx), None => Vec::new(), }; // Keyboard focus rides the panel across frames. let focus = gpui_native_utils::focus_handle(&self.id, window, cx); let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); let selected = if *was_open.read(cx) { *selection.read(cx) } else { let first = items.iter().position(|item| item.selectable()).unwrap_or(0); selection.update(cx, |selection, _| *selection = first); first }; was_open.update(cx, |was_open, _| *was_open = true); let rows: Vec = items .iter() .enumerate() .map(|(index, item)| { render_item( item, index, selected == index, &selection, &on_change, &chrome, ) }) .collect(); let panel = v_flex() .id("menu-panel") .track_focus(&focus) .role(Role::Menu) .aria_label(label) .mt_1() .min_w_40() .max_h(relative(0.75)) .p_1() .bg(chrome.surface) .text_color(chrome.foreground) .rounded(chrome.radius_overlay) .border_1() .border_color(chrome.border) .shadow(chrome.shadow.clone()) .overflow_hidden() .on_key_down({ let items = items.clone(); let on_change = on_change.clone(); let selection = selection.clone(); move |event: &KeyDownEvent, window, cx| { handle_menu_key(event, &items, &selection, &on_change, window, cx); } }) .child( ScrollArea::new("menu-items") .handle(scroll.read(cx).clone()) .axes(ScrollAxes::Vertical) .max_h(px(320.)) .children(rows), ) .child( canvas( { let cell = panel_bounds.clone(); move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) }, { let on_change = on_change.clone(); move |_, _, window, cx| { if !focus.contains_focused(window, cx) { window.focus(&focus, cx); } window.on_mouse_event({ let on_change = on_change.clone(); move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble || event.button != MouseButton::Left { return; } let on_panel = panel_bounds .borrow() .is_some_and(|bounds| bounds.contains(&event.position)); let on_trigger = trigger_bounds .borrow() .is_some_and(|bounds| bounds.contains(&event.position)); if on_panel || on_trigger { return; } if let Some(on_change) = &on_change { on_change(false, window, cx); } cx.stop_propagation(); } }); } }, ) .absolute() .inset_0(), ); wrapper = wrapper.child(deferred(anchored().child(panel.with_animation( "menu-enter", Animation::new(enter_duration).with_easing(ease_out_quint()), |panel, delta| panel.opacity(delta), )))); } wrapper } } /// Renders one menu row. fn render_item( item: &MenuItem, index: usize, selected: bool, selection: &Entity, on_change: &Option, chrome: &MenuChrome, ) -> AnyElement { match item.kind { MenuItemKind::Separator => div() .h(px(1.)) .mx_2() .my_1() .bg(chrome.border) .into_any_element(), MenuItemKind::Action => { let label_color = row_color(item, selected, chrome); let disabled = item.disabled; let destructive = item.destructive; let checked = item.checked; let shortcut = item.shortcut.clone(); let icon_role = item.icon; div() .id(("menu-item", index)) .flex() .items_center() .gap_2() .px_2() .py_1() .rounded(chrome.radius_sm) .map(|row| { if disabled { row } else if selected { let highlight = if destructive { chrome.destructive } else { chrome.primary }; row.bg(highlight) } else if destructive { row.hover(|row| row.bg(chrome.destructive)) } else { row.hover(|row| row.bg(chrome.muted_foreground)) } }) .on_mouse_move({ let selection = selection.clone(); move |_, _window, cx| { if *selection.read(cx) != index { selection.update(cx, |selection, _| *selection = index); } } }) .on_click({ let handler = item.handler.clone(); let on_change = on_change.clone(); move |_, window, cx| { if disabled { return; } if let Some(on_change) = &on_change { on_change(false, window, cx); } if let Some(handler) = &handler { handler(window, cx); } } }) .when_some(checked, |row, checked| { row.child(div().flex_none().when(checked, |slot| { slot.child( icon_glyph(IconRole::Check) .size(chrome.icon_size * 0.75) .color(label_color), ) })) }) .when_some(icon_role, |row, role| { row.child(icon_glyph(role).size(chrome.icon_size).color(label_color)) }) .child( div() .flex_1() .text_color(label_color) .child(item.label.clone()), ) .when_some(shortcut, |row, shortcut| { row.child( div() .text_color(if selected && !destructive && !disabled { chrome.primary_foreground } else { chrome.muted_foreground }) .child(shortcut), ) }) .into_any_element() } } } /// The row's label/icon color. fn row_color(item: &MenuItem, selected: bool, chrome: &MenuChrome) -> gpui::Hsla { if item.disabled { return chrome.muted_foreground; } if item.destructive { return if selected { chrome.destructive_foreground } else { chrome.destructive }; } if selected { chrome.primary_foreground } else { chrome.foreground } } /// Handles the panel's navigation keys. fn handle_menu_key( event: &KeyDownEvent, items: &[MenuItem], selection: &Entity, on_change: &Option, window: &mut Window, cx: &mut App, ) { let selectable: Vec = items .iter() .enumerate() .filter(|(_, item)| item.selectable()) .map(|(index, _)| index) .collect(); let current = *selection.read(cx); let position = selectable.iter().position(|index| *index == current); match event.keystroke.key.as_str() { "down" => { let next = match position { Some(pos) => (pos + 1).min(selectable.len() - 1), None => 0, }; selection.update(cx, |selection, _| *selection = selectable[next]); } "up" => { let next = match position { Some(pos) => pos.saturating_sub(1), None => selectable.len() - 1, }; selection.update(cx, |selection, _| *selection = selectable[next]); } "home" => selection.update(cx, |selection, _| *selection = selectable[0]), "end" => selection.update(cx, |selection, _| { *selection = selectable[selectable.len() - 1] }), "enter" | "space" => { if let Some(item) = items.get(current) && item.selectable() { if let Some(on_change) = on_change { on_change(false, window, cx); } if let Some(handler) = &item.handler { handler(window, cx); } } } "escape" => { if let Some(on_change) = on_change { on_change(false, window, cx); } } _ => return, } cx.stop_propagation(); }