use crate::{ h_stack, prelude::*, v_stack, Icon, IconElement, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader, }; use gpui::{ px, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, IntoElement, Render, Subscription, View, VisualContext, }; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; use std::{rc::Rc, time::Duration}; enum ContextMenuItem { Separator, Header(SharedString), Entry { label: SharedString, icon: Option, handler: Rc, action: Option>, }, CustomEntry { entry_render: Box AnyElement>, handler: Rc, }, } pub struct ContextMenu { items: Vec, focus_handle: FocusHandle, selected_index: Option, delayed: bool, clicked: bool, _on_blur_subscription: Subscription, } impl FocusableView for ContextMenu { fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { self.focus_handle.clone() } } impl EventEmitter for ContextMenu {} impl ContextMenu { pub fn build( cx: &mut WindowContext, f: impl FnOnce(Self, &mut WindowContext) -> Self, ) -> View { cx.new_view(|cx| { let focus_handle = cx.focus_handle(); let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| { this.cancel(&menu::Cancel, cx) }); f( Self { items: Default::default(), focus_handle, selected_index: None, delayed: false, clicked: false, _on_blur_subscription, }, cx, ) }) } pub fn header(mut self, title: impl Into) -> Self { self.items.push(ContextMenuItem::Header(title.into())); self } pub fn separator(mut self) -> Self { self.items.push(ContextMenuItem::Separator); self } pub fn entry( mut self, label: impl Into, action: Option>, handler: impl Fn(&mut WindowContext) + 'static, ) -> Self { self.items.push(ContextMenuItem::Entry { label: label.into(), handler: Rc::new(handler), icon: None, action, }); self } pub fn custom_entry( mut self, entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static, handler: impl Fn(&mut WindowContext) + 'static, ) -> Self { self.items.push(ContextMenuItem::CustomEntry { entry_render: Box::new(entry_render), handler: Rc::new(handler), }); self } pub fn action(mut self, label: impl Into, action: Box) -> Self { self.items.push(ContextMenuItem::Entry { label: label.into(), action: Some(action.boxed_clone()), handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), icon: None, }); self } pub fn link(mut self, label: impl Into, action: Box) -> Self { self.items.push(ContextMenuItem::Entry { label: label.into(), action: Some(action.boxed_clone()), handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), icon: Some(Icon::Link), }); self } pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { match self.selected_index.and_then(|ix| self.items.get(ix)) { Some( ContextMenuItem::Entry { handler, .. } | ContextMenuItem::CustomEntry { handler, .. }, ) => (handler)(cx), _ => {} } cx.emit(DismissEvent); } pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { cx.emit(DismissEvent); cx.emit(DismissEvent); } fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { self.selected_index = self.items.iter().position(|item| item.is_selectable()); cx.notify(); } pub fn select_last(&mut self) -> Option { for (ix, item) in self.items.iter().enumerate().rev() { if item.is_selectable() { self.selected_index = Some(ix); return Some(ix); } } None } fn handle_select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { if self.select_last().is_some() { cx.notify(); } } fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { if let Some(ix) = self.selected_index { for (ix, item) in self.items.iter().enumerate().skip(ix + 1) { if item.is_selectable() { self.selected_index = Some(ix); cx.notify(); break; } } } else { self.select_first(&Default::default(), cx); } } pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { if let Some(ix) = self.selected_index { for (ix, item) in self.items.iter().enumerate().take(ix).rev() { if item.is_selectable() { self.selected_index = Some(ix); cx.notify(); break; } } } else { self.handle_select_last(&Default::default(), cx); } } pub fn on_action_dispatch(&mut self, dispatched: &Box, cx: &mut ViewContext) { if self.clicked { cx.propagate(); return; } if let Some(ix) = self.items.iter().position(|item| { if let ContextMenuItem::Entry { action: Some(action), .. } = item { action.partial_eq(&**dispatched) } else { false } }) { self.selected_index = Some(ix); self.delayed = true; cx.notify(); let action = dispatched.boxed_clone(); cx.spawn(|this, mut cx| async move { cx.background_executor() .timer(Duration::from_millis(50)) .await; this.update(&mut cx, |this, cx| { cx.dispatch_action(action); this.cancel(&menu::Cancel, cx) }) }) .detach_and_log_err(cx); } else { cx.propagate() } } } impl ContextMenuItem { fn is_selectable(&self) -> bool { matches!(self, Self::Entry { .. } | Self::CustomEntry { .. }) } } impl Render for ContextMenu { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { div().elevation_2(cx).flex().flex_row().child( v_stack() .min_w(px(200.)) .track_focus(&self.focus_handle) .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx))) .key_context("menu") .on_action(cx.listener(ContextMenu::select_first)) .on_action(cx.listener(ContextMenu::handle_select_last)) .on_action(cx.listener(ContextMenu::select_next)) .on_action(cx.listener(ContextMenu::select_prev)) .on_action(cx.listener(ContextMenu::confirm)) .on_action(cx.listener(ContextMenu::cancel)) .when(!self.delayed, |mut el| { for item in self.items.iter() { if let ContextMenuItem::Entry { action: Some(action), .. } = item { el = el.on_boxed_action( &**action, cx.listener(ContextMenu::on_action_dispatch), ); } } el }) .flex_none() .child(List::new().children(self.items.iter_mut().enumerate().map( |(ix, item)| match item { ContextMenuItem::Separator => ListSeparator.into_any_element(), ContextMenuItem::Header(header) => { ListSubHeader::new(header.clone()).into_any_element() } ContextMenuItem::Entry { label, handler, icon, action, } => { let handler = handler.clone(); let menu = cx.view().downgrade(); let label_element = if let Some(icon) = icon { h_stack() .gap_1() .child(Label::new(label.clone())) .child(IconElement::new(*icon)) .into_any_element() } else { Label::new(label.clone()).into_any_element() }; ListItem::new(ix) .inset(true) .selected(Some(ix) == self.selected_index) .on_click(move |_, cx| { handler(cx); menu.update(cx, |menu, cx| { menu.clicked = true; cx.emit(DismissEvent); }) .ok(); }) .child( h_stack() .w_full() .justify_between() .child(label_element) .children(action.as_ref().and_then(|action| { KeyBinding::for_action(&**action, cx) .map(|binding| div().ml_1().child(binding)) })), ) .into_any_element() } ContextMenuItem::CustomEntry { entry_render, handler, } => { let handler = handler.clone(); let menu = cx.view().downgrade(); ListItem::new(ix) .inset(true) .selected(Some(ix) == self.selected_index) .on_click(move |_, cx| { handler(cx); menu.update(cx, |menu, cx| { menu.clicked = true; cx.emit(DismissEvent); }) .ok(); }) .child(entry_render(cx)) .into_any_element() } }, ))), ) } }