use std::rc::Rc;
use gpui::{
Action, AnyElement, App, Context, ElementId, Entity, FocusHandle, IntoElement, KeyDownEvent,
MouseButton, MouseDownEvent, ParentElement, Pixels, Point, ScrollHandle, SharedString, Styled,
Svg, Window, anchored, deferred, div, prelude::*, px,
};
use crate::element_id::scoped;
use crate::elements::kbd::kbd;
use crate::icons::Icons;
use crate::theme::{ActiveTheme, Themeable};
use crate::traits::control_sized::ControlSized;
fn menu_element_id(menu_id: &ElementId) -> ElementId {
scoped(menu_id, "popup")
}
pub type IconFactory = Rc<dyn Fn() -> Svg>;
type ClickHandler = Rc<dyn Fn(&mut Window, &mut App)>;
type MenuBuilder = Rc<dyn Fn(MenuItems, &mut Window, &mut App) -> MenuItems>;
pub struct MenuItem {
label: SharedString,
icon: Option<IconFactory>,
kbd: Option<SharedString>,
action: Option<Box<dyn Action>>,
on_click: Option<ClickHandler>,
disabled: bool,
destructive: bool,
toggled: Option<bool>,
}
pub fn menu_item(label: impl Into<SharedString>) -> MenuItem {
MenuItem::new(label)
}
impl MenuItem {
pub fn new(label: impl Into<SharedString>) -> Self {
Self {
label: label.into(),
icon: None,
kbd: None,
action: None,
on_click: None,
disabled: false,
destructive: false,
toggled: None,
}
}
pub fn icon(mut self, icon: impl Fn() -> Svg + 'static) -> Self {
self.icon = Some(Rc::new(icon));
self
}
pub fn kbd(mut self, shortcut: impl Into<SharedString>) -> Self {
self.kbd = Some(shortcut.into());
self
}
pub fn action(mut self, action: Box<dyn Action>) -> Self {
self.action = Some(action);
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
pub fn toggled(mut self, toggled: bool) -> Self {
self.toggled = Some(toggled);
self
}
}
#[non_exhaustive]
pub enum MenuEntry {
Item(MenuItem),
Separator,
Header(SharedString),
}
#[derive(Default)]
pub struct MenuItems {
entries: Vec<MenuEntry>,
}
impl MenuItems {
pub fn new() -> Self {
Self::default()
}
pub fn item(mut self, item: MenuItem) -> Self {
self.entries.push(MenuEntry::Item(item));
self
}
pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
self.entries.extend(items.into_iter().map(MenuEntry::Item));
self
}
pub fn separator(mut self) -> Self {
if matches!(
self.entries.last(),
None | Some(MenuEntry::Separator) | Some(MenuEntry::Header(_))
) {
return self;
}
self.entries.push(MenuEntry::Separator);
self
}
pub fn header(mut self, label: impl Into<SharedString>) -> Self {
self.entries.push(MenuEntry::Header(label.into()));
self
}
pub fn when(self, condition: bool, f: impl FnOnce(Self) -> Self) -> Self {
if condition { f(self) } else { self }
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
fn finish(mut self) -> Vec<MenuEntry> {
while matches!(
self.entries.last(),
Some(MenuEntry::Separator) | Some(MenuEntry::Header(_))
) {
self.entries.pop();
}
self.entries
}
}
fn selectable_indices(entries: &[MenuEntry]) -> Vec<usize> {
entries
.iter()
.enumerate()
.filter_map(|(ix, entry)| match entry {
MenuEntry::Item(item) if !item.disabled => Some(ix),
_ => None,
})
.collect()
}
fn next_focus(selectable: &[usize], current: Option<usize>, delta: isize) -> Option<usize> {
if selectable.is_empty() {
return None;
}
let position = current.and_then(|ix| selectable.iter().position(|&s| s == ix));
match position {
Some(pos) => crate::selection::wrap_index(Some(pos), delta, selectable.len())
.and_then(|moved| selectable.get(moved).copied()),
None if delta >= 0 => selectable.first().copied(),
None => selectable.last().copied(),
}
}
struct OpenMenu {
position: Point<Pixels>,
entries: Vec<MenuEntry>,
focused: Option<usize>,
restore_focus: Option<FocusHandle>,
scroll: ScrollHandle,
}
struct MenuState {
focus_handle: FocusHandle,
open: Option<OpenMenu>,
}
impl MenuState {
fn new(cx: &mut Context<Self>) -> Self {
Self {
focus_handle: cx.focus_handle(),
open: None,
}
}
fn open(
&mut self,
position: Point<Pixels>,
entries: Vec<MenuEntry>,
window: &mut Window,
cx: &mut Context<Self>,
) {
if entries.is_empty() {
return;
}
let restore_focus = window.focused(cx);
self.open = Some(OpenMenu {
position,
entries,
focused: None,
restore_focus,
scroll: ScrollHandle::new(),
});
window.focus(&self.focus_handle, cx);
cx.notify();
}
fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(open) = self.open.take() else {
return;
};
if let Some(handle) = open.restore_focus {
window.focus(&handle, cx);
}
cx.notify();
}
fn move_focus(&mut self, delta: isize, cx: &mut Context<Self>) {
let Some(open) = self.open.as_mut() else {
return;
};
let Some(next) = next_focus(&selectable_indices(&open.entries), open.focused, delta) else {
return;
};
open.focused = Some(next);
open.scroll.scroll_to_item(next);
cx.notify();
}
fn focus_edge(&mut self, delta: isize, cx: &mut Context<Self>) {
if let Some(open) = self.open.as_mut() {
open.focused = None;
}
self.move_focus(delta, cx);
}
fn focus_from_hover(&mut self, index: Option<usize>, cx: &mut Context<Self>) {
let Some(open) = self.open.as_mut() else {
return;
};
if open.focused == index {
return;
}
open.focused = index;
cx.notify();
}
fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
let Some(open) = self.open.as_ref() else {
return;
};
let Some(MenuEntry::Item(item)) = open.entries.get(index) else {
return;
};
if item.disabled {
return;
}
let on_click = item.on_click.clone();
let action = item.action.as_ref().map(|action| action.boxed_clone());
let restore_focus = open.restore_focus.clone();
self.close(window, cx);
if let Some(on_click) = on_click {
on_click(window, cx);
}
if let Some(action) = action {
match restore_focus {
Some(handle) => handle.dispatch_action(action.as_ref(), window, cx),
None => window.dispatch_action(action, cx),
}
}
}
}
enum Row {
Item {
index: usize,
label: SharedString,
icon: Option<IconFactory>,
kbd: Option<SharedString>,
disabled: bool,
destructive: bool,
toggled: Option<bool>,
focused: bool,
},
Separator,
Header(SharedString),
}
fn shortcut_for(item: &MenuItem, window: &Window) -> Option<SharedString> {
if let Some(shortcut) = item.kbd.clone() {
return Some(shortcut);
}
let action = item.action.as_ref()?;
let binding = window.highest_precedence_binding_for_action(action.as_ref())?;
let text = binding
.keystrokes()
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(" ");
if text.is_empty() {
None
} else {
Some(text.into())
}
}
fn rows_for(open: &OpenMenu, window: &Window) -> Vec<Row> {
open.entries
.iter()
.enumerate()
.map(|(index, entry)| match entry {
MenuEntry::Separator => Row::Separator,
MenuEntry::Header(label) => Row::Header(label.clone()),
MenuEntry::Item(item) => Row::Item {
index,
label: item.label.clone(),
icon: item.icon.clone(),
kbd: shortcut_for(item, window),
disabled: item.disabled,
destructive: item.destructive,
toggled: item.toggled,
focused: open.focused == Some(index),
},
})
.collect()
}
#[derive(IntoElement)]
pub struct ContextMenu {
id: ElementId,
trigger: AnyElement,
builder: Option<MenuBuilder>,
min_width: Pixels,
max_height: Pixels,
}
pub fn context_menu(id: impl Into<ElementId>, trigger: impl IntoElement) -> ContextMenu {
ContextMenu {
id: id.into(),
trigger: trigger.into_any_element(),
builder: None,
min_width: px(180.),
max_height: px(420.),
}
}
impl ContextMenu {
pub fn menu(
mut self,
builder: impl Fn(MenuItems, &mut Window, &mut App) -> MenuItems + 'static,
) -> Self {
self.builder = Some(Rc::new(builder));
self
}
pub fn min_width(mut self, width: Pixels) -> Self {
self.min_width = width;
self
}
pub fn max_height(mut self, height: Pixels) -> Self {
self.max_height = height;
self
}
}
impl RenderOnce for ContextMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_window, cx| MenuState::new(cx));
let popup_id = menu_element_id(&self.id);
let ContextMenu {
trigger,
builder,
min_width,
max_height,
..
} = self;
let open = {
let menu = state.read(cx);
menu.open.as_ref().map(|open| {
(
open.position,
rows_for(open, window),
open.scroll.clone(),
menu.focus_handle.clone(),
)
})
};
let trigger = div()
.when_some(builder, |el, builder| {
let state = state.clone();
el.on_mouse_down(
MouseButton::Right,
move |event: &MouseDownEvent, window, cx| {
window.prevent_default();
let entries = builder(MenuItems::new(), window, cx).finish();
let position = event.position;
state.update(cx, |menu, cx| menu.open(position, entries, window, cx));
cx.stop_propagation();
},
)
})
.child(trigger);
div()
.child(trigger)
.when_some(open, |el, (position, rows, scroll, focus_handle)| {
el.child(
deferred(
anchored()
.position(position)
.snap_to_window_with_margin(px(8.))
.child(div().occlude().child(menu_popup(
popup_id,
rows,
scroll,
focus_handle,
state,
min_width,
max_height,
cx,
))),
)
.with_priority(1),
)
})
}
}
#[allow(clippy::too_many_arguments)]
fn menu_popup(
popup_id: ElementId,
rows: Vec<Row>,
scroll: ScrollHandle,
focus_handle: FocusHandle,
state: Entity<MenuState>,
min_width: Pixels,
max_height: Pixels,
cx: &App,
) -> impl IntoElement + use<> {
let theme = cx.theme();
div()
.id(popup_id)
.track_focus(&focus_handle)
.on_mouse_down_out({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |menu, cx| menu.close(window, cx));
}
})
.on_key_down({
let state = state.clone();
move |event: &KeyDownEvent, window, cx| {
let handled = match event.keystroke.key.as_str() {
"escape" => {
state.update(cx, |menu, cx| menu.close(window, cx));
true
}
"up" => {
state.update(cx, |menu, cx| menu.move_focus(-1, cx));
true
}
"down" => {
state.update(cx, |menu, cx| menu.move_focus(1, cx));
true
}
"home" => {
state.update(cx, |menu, cx| menu.focus_edge(1, cx));
true
}
"end" => {
state.update(cx, |menu, cx| menu.focus_edge(-1, cx));
true
}
"enter" | "space" => state.update(cx, |menu, cx| {
let focused = menu.open.as_ref().and_then(|open| open.focused);
match focused {
Some(index) => {
menu.activate(index, window, cx);
true
}
None => false,
}
}),
_ => false,
};
if handled {
cx.stop_propagation();
}
}
})
.min_w(min_width)
.max_h(max_height)
.overflow_y_scroll()
.track_scroll(&scroll)
.on_scroll_wheel(|_, _, cx| {
cx.stop_propagation();
})
.bg(theme.surface())
.border_1()
.border_color(theme.border())
.rounded_md()
.shadow_lg()
.py_1()
.flex()
.flex_col()
.children(
rows.into_iter()
.map(|row| menu_row(row, state.clone(), cx).into_any_element()),
)
}
fn clears_the_highlight(
state: Entity<MenuState>,
) -> impl Fn(&gpui::MouseMoveEvent, &mut Window, &mut App) + 'static {
move |_, _window, cx| {
state.update(cx, |menu, cx| menu.focus_from_hover(None, cx));
}
}
fn menu_row(row: Row, state: Entity<MenuState>, cx: &App) -> impl IntoElement + use<> {
let theme = cx.theme();
match row {
Row::Separator => div()
.my_1()
.h(px(1.))
.bg(theme.border_subtle())
.on_mouse_move(clears_the_highlight(state))
.into_any_element(),
Row::Header(label) => div()
.px_3()
.pt_2()
.pb_1()
.text_xs()
.text_color(theme.fg_muted())
.child(label)
.on_mouse_move(clears_the_highlight(state))
.into_any_element(),
Row::Item {
index,
label,
icon,
kbd: shortcut,
disabled,
destructive,
toggled,
focused,
} => {
let text_color = if disabled {
theme.fg_disabled()
} else if destructive {
theme.danger()
} else {
theme.fg()
};
let icon_color = if disabled {
theme.fg_disabled()
} else if destructive {
theme.danger()
} else {
theme.fg_muted()
};
let mut row = div()
.id(("gpuikit-context-menu-item", index))
.px_3()
.py_1()
.mx_1()
.rounded_sm()
.text_xs()
.flex()
.items_center()
.gap_2()
.text_color(text_color);
if disabled {
row = row.cursor_not_allowed().on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
state.update(cx, |menu, cx| menu.focus_from_hover(None, cx));
}
});
} else {
row = row
.cursor_pointer()
.when(focused, |this| this.bg(theme.surface_secondary()))
.on_mouse_move({
let state = state.clone();
move |_, _window, cx| {
state.update(cx, |menu, cx| menu.focus_from_hover(Some(index), cx));
}
})
.on_click({
let state = state.clone();
move |_, window, cx| {
state.update(cx, |menu, cx| menu.activate(index, window, cx));
}
});
}
if let Some(toggled) = toggled {
row = row.child(div().w(px(14.)).flex_shrink_0().when(toggled, |this| {
this.child(Icons::check().size(px(14.)).text_color(icon_color))
}));
}
let row = row
.when_some(icon, |this, icon| {
this.child(icon().size(px(14.)).text_color(icon_color).flex_shrink_0())
})
.child(div().flex_1().child(label))
.when_some(shortcut, |this, shortcut| this.child(kbd(shortcut).small()));
#[cfg(test)]
let row = row.debug_selector(|| format!("gpuikit-context-menu-item-{index}"));
row.into_any_element()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{Modifiers, MouseUpEvent, Render, TestAppContext, VisualTestContext, point};
use std::cell::RefCell;
fn labels(entries: &[MenuEntry]) -> Vec<&str> {
entries
.iter()
.map(|entry| match entry {
MenuEntry::Item(item) => item.label.as_ref(),
MenuEntry::Separator => "---",
MenuEntry::Header(label) => label.as_ref(),
})
.collect()
}
#[test]
fn each_menu_pops_up_under_its_own_id() {
let row = ElementId::named_usize("row", 3);
let other_row = ElementId::named_usize("row", 4);
assert_ne!(menu_element_id(&row), menu_element_id(&other_row));
assert_eq!(menu_element_id(&row), menu_element_id(&row));
assert_ne!(menu_element_id(&row), row);
}
#[test]
fn separators_never_lead_or_double_up() {
let entries = MenuItems::new()
.separator()
.item(menu_item("Copy"))
.separator()
.separator()
.item(menu_item("Delete"))
.finish();
assert_eq!(labels(&entries), vec!["Copy", "---", "Delete"]);
}
#[test]
fn trailing_separators_are_dropped() {
let entries = MenuItems::new()
.item(menu_item("Copy"))
.separator()
.when(false, |menu| menu.item(menu_item("Delete")))
.finish();
assert_eq!(labels(&entries), vec!["Copy"]);
}
#[test]
fn an_empty_trailing_section_leaves_nothing_behind() {
let entries = MenuItems::new()
.item(menu_item("Copy"))
.separator()
.header("Danger")
.when(false, |menu| menu.item(menu_item("Delete")))
.finish();
assert_eq!(labels(&entries), vec!["Copy"]);
}
#[test]
fn a_menu_that_reduces_to_nothing_is_empty() {
let entries = MenuItems::new()
.header("Danger")
.when(false, |menu| menu.item(menu_item("Delete")))
.finish();
assert!(entries.is_empty());
}
#[test]
fn a_separator_after_a_header_is_dropped() {
let entries = MenuItems::new()
.item(menu_item("Copy"))
.separator()
.header("Danger")
.separator()
.item(menu_item("Delete"))
.finish();
assert_eq!(labels(&entries), vec!["Copy", "---", "Danger", "Delete"]);
}
#[test]
fn keyboard_navigation_skips_headers_separators_and_disabled_items() {
let entries = MenuItems::new()
.header("Group")
.item(menu_item("Copy"))
.item(menu_item("Paste").disabled(true))
.separator()
.item(menu_item("Delete"))
.finish();
assert_eq!(selectable_indices(&entries), vec![1, 4]);
}
#[test]
fn arrowing_into_a_fresh_menu_enters_from_the_end_it_moves_from() {
let selectable = vec![1, 4];
assert_eq!(next_focus(&selectable, None, 1), Some(1));
assert_eq!(next_focus(&selectable, None, -1), Some(4));
}
#[test]
fn focus_wraps_at_both_ends() {
let selectable = vec![1, 4];
assert_eq!(next_focus(&selectable, Some(1), 1), Some(4));
assert_eq!(next_focus(&selectable, Some(4), 1), Some(1));
assert_eq!(next_focus(&selectable, Some(1), -1), Some(4));
}
#[test]
fn a_menu_with_nothing_selectable_never_focuses() {
let entries = MenuItems::new()
.header("Group")
.item(menu_item("Copy").disabled(true))
.finish();
let selectable = selectable_indices(&entries);
assert!(selectable.is_empty());
assert_eq!(next_focus(&selectable, None, 1), None);
assert_eq!(next_focus(&selectable, None, -1), None);
}
#[test]
fn focus_survives_an_entry_that_is_no_longer_selectable() {
let selectable = vec![0, 2];
assert_eq!(next_focus(&selectable, Some(1), 1), Some(0));
}
#[derive(Clone, Default)]
struct Chosen(Rc<RefCell<Vec<String>>>);
impl Chosen {
fn record(&self, label: &'static str) -> impl Fn(&mut Window, &mut App) + use<> {
let chosen = self.0.clone();
move |_window, _cx| chosen.borrow_mut().push(label.to_string())
}
fn get(&self) -> Vec<String> {
self.0.borrow().clone()
}
}
struct TestView {
chosen: Chosen,
}
impl Render for TestView {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let chosen = self.chosen.clone();
context_menu("test-menu", div().w(px(400.)).h(px(300.))).menu(
move |menu, _window, _cx| {
menu.item(menu_item("Copy").on_click(chosen.record("Copy")))
.item(
menu_item("Paste")
.disabled(true)
.on_click(chosen.record("Paste")),
)
.separator()
.item(menu_item("Delete").on_click(chosen.record("Delete")))
},
)
}
}
fn open_menu(cx: &mut TestAppContext) -> (Chosen, &mut VisualTestContext) {
cx.update(crate::theme::init);
let chosen = Chosen::default();
let (_view, cx) = cx.add_window_view({
let chosen = chosen.clone();
move |_window, _cx| TestView { chosen }
});
right_click(cx, point(px(50.), px(50.)));
(chosen, cx)
}
fn right_click(cx: &mut VisualTestContext, position: Point<Pixels>) {
cx.simulate_event(MouseDownEvent {
position,
modifiers: Modifiers::default(),
button: MouseButton::Right,
click_count: 1,
first_mouse: false,
});
cx.simulate_event(MouseUpEvent {
position,
modifiers: Modifiers::default(),
button: MouseButton::Right,
click_count: 1,
});
}
#[gpui::test]
fn right_click_opens_the_menu_and_enter_chooses(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
cx.simulate_keystrokes("down enter");
assert_eq!(chosen.get(), vec!["Copy".to_string()]);
}
#[gpui::test]
fn arrowing_past_a_disabled_item_lands_on_the_next_enabled_one(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
cx.simulate_keystrokes("down down enter");
assert_eq!(chosen.get(), vec!["Delete".to_string()]);
}
#[gpui::test]
fn escape_closes_the_menu_without_choosing(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
cx.simulate_keystrokes("down escape");
cx.simulate_keystrokes("down enter");
assert!(chosen.get().is_empty(), "{:?}", chosen.get());
}
#[gpui::test]
fn clicking_away_closes_the_menu(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
cx.simulate_click(point(px(900.), px(700.)), Modifiers::default());
cx.simulate_keystrokes("down enter");
assert!(chosen.get().is_empty(), "{:?}", chosen.get());
}
#[gpui::test]
fn clicking_an_item_chooses_it(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
let bounds = cx
.debug_bounds("gpuikit-context-menu-item-3")
.expect("the Delete row should have been laid out");
cx.simulate_click(bounds.center(), Modifiers::default());
assert_eq!(chosen.get(), vec!["Delete".to_string()]);
}
#[gpui::test]
fn hovering_an_item_arms_enter_for_it(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
let delete = cx
.debug_bounds("gpuikit-context-menu-item-3")
.expect("the Delete row should have been laid out");
cx.simulate_mouse_move(delete.center(), None, Modifiers::default());
cx.simulate_keystrokes("enter");
assert_eq!(chosen.get(), vec!["Delete".to_string()]);
}
#[gpui::test]
fn hovering_a_disabled_item_clears_the_highlight(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
let copy = cx
.debug_bounds("gpuikit-context-menu-item-0")
.expect("the Copy row should have been laid out");
let paste = cx
.debug_bounds("gpuikit-context-menu-item-1")
.expect("the Paste row should have been laid out");
cx.simulate_mouse_move(copy.center(), None, Modifiers::default());
cx.simulate_mouse_move(paste.center(), None, Modifiers::default());
cx.simulate_keystrokes("enter");
assert!(
chosen.get().is_empty(),
"Enter ran {:?} while the pointer was on a disabled row",
chosen.get()
);
}
#[gpui::test]
fn clicking_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
let bounds = cx
.debug_bounds("gpuikit-context-menu-item-1")
.expect("the Paste row should have been laid out");
cx.simulate_click(bounds.center(), Modifiers::default());
assert!(chosen.get().is_empty(), "{:?}", chosen.get());
cx.simulate_keystrokes("down enter");
assert_eq!(chosen.get(), vec!["Copy".to_string()]);
}
#[gpui::test]
fn right_clicking_again_moves_the_open_menu(cx: &mut TestAppContext) {
let (chosen, cx) = open_menu(cx);
right_click(cx, point(px(120.), px(90.)));
cx.simulate_keystrokes("down enter");
assert_eq!(chosen.get(), vec!["Copy".to_string()]);
}
}