use std::rc::Rc; use gpui::FocusableView; use gpui::{ actions, div, prelude::FluentBuilder, px, Action, AppContext, DismissEvent, EventEmitter, FocusHandle, InteractiveElement, IntoElement, KeyBinding, ParentElement, Pixels, Render, SharedString, Styled as _, View, ViewContext, VisualContext as _, WindowContext, }; use crate::{ button::Button, h_flex, list::ListItem, popover::Popover, styled_ext::Sizable as _, theme::ActiveTheme, v_flex, Icon, IconName, Selectable, }; actions!(menu, [Confirm, Dismiss, SelectNext, SelectPrev]); pub fn init(cx: &mut AppContext) { let context = Some("PopupMenu"); cx.bind_keys([ KeyBinding::new("enter", Confirm, context), KeyBinding::new("escape", Dismiss, context), KeyBinding::new("up", SelectPrev, context), KeyBinding::new("down", SelectNext, context), ]); } pub trait PopupMenuExt: Selectable + IntoElement + 'static { fn popup_menu( self, f: impl Fn(PopupMenu, &mut WindowContext) -> PopupMenu + 'static, ) -> Popover { Popover::new("popup-menu") .trigger(self) .content(move |cx| PopupMenu::build(cx, |menu, cx| f(menu, cx))) } } impl PopupMenuExt for Button {} enum PopupMenuItem { Separator, Item { icon: Option, label: SharedString, handler: Rc, }, } impl PopupMenuItem { fn is_clickable(&self) -> bool { !matches!(self, PopupMenuItem::Separator) } fn has_icon(&self) -> bool { matches!(self, PopupMenuItem::Item { icon: Some(_), .. }) } } pub struct PopupMenu { focus_handle: FocusHandle, menu_items: Vec, has_icon: bool, selected_index: Option, min_width: Pixels, max_width: Pixels, _subscriptions: [gpui::Subscription; 1], } impl PopupMenu { 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 PopupMenu, cx| { this.dismiss(&Dismiss, cx) }); let menu = Self { focus_handle, menu_items: Vec::new(), selected_index: None, min_width: px(120.), max_width: px(500.), has_icon: false, _subscriptions: [_on_blur_subscription], }; cx.refresh(); f(menu, cx) }) } /// Set min width of the popup menu, default is 120px pub fn min_w(mut self, width: impl Into) -> Self { self.min_width = width.into(); self } /// Set max width of the popup menu, default is 500px pub fn max_w(mut self, height: impl Into) -> Self { self.max_width = height.into(); self } /// Add Menu Item pub fn menu(mut self, label: impl Into, action: Box) -> Self { self.add_menu_item(label, None, action); self } /// Add Menu to open link pub fn link(mut self, label: impl Into, href: impl Into) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: None, label: label.into(), handler: Rc::new(move |cx| cx.open_url(&href)), }); self } /// Add Menu to open link pub fn link_with_icon( mut self, label: impl Into, icon: impl Into, href: impl Into, ) -> Self { let href = href.into(); self.menu_items.push(PopupMenuItem::Item { icon: Some(icon.into()), label: label.into(), handler: Rc::new(move |cx| cx.open_url(&href)), }); self } /// Add Menu Item with Icon pub fn menu_with_icon( mut self, label: impl Into, icon: impl Into, action: Box, ) -> Self { self.add_menu_item(label, Some(icon.into()), action); self } /// Add Menu Item with check icon pub fn menu_with_check( mut self, label: impl Into, checked: bool, action: Box, ) -> Self { if checked { self.add_menu_item(label, Some(IconName::Check.into()), action); } else { self.add_menu_item(label, None, action); } self } fn add_menu_item( &mut self, label: impl Into, icon: Option, action: Box, ) -> &mut Self { if icon.is_some() { self.has_icon = true; } self.menu_items.push(PopupMenuItem::Item { icon, label: label.into(), handler: Rc::new(move |cx| { cx.activate_window(); cx.dispatch_action(action.boxed_clone()); }), }); self } /// Add a separator Menu Item pub fn separator(mut self) -> Self { self.menu_items.push(PopupMenuItem::Separator); self } fn clickable_menu_items(&self) -> impl Iterator { self.menu_items .iter() .enumerate() .filter(|(_, item)| item.is_clickable()) } fn on_click(&mut self, ix: usize, cx: &mut ViewContext) { cx.stop_propagation(); cx.prevent_default(); self.selected_index = Some(ix); self.confirm(&Confirm, cx) } fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext) { match self.selected_index { Some(index) => { let item = self.menu_items.get(index); match item { Some(PopupMenuItem::Item { handler, .. }) => { handler(cx); self.dismiss(&Dismiss, cx) } _ => {} } } _ => {} } } fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { let count = self.clickable_menu_items().count(); if count > 0 { let ix = self .selected_index .map(|index| if index == count - 1 { 0 } else { index + 1 }) .unwrap_or(0); self.selected_index = Some(ix); cx.notify(); } } fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { let count = self.clickable_menu_items().count(); if count > 0 { let ix = self .selected_index .map(|index| if index == count - 1 { 0 } else { index - 1 }) .unwrap_or(count - 1); self.selected_index = Some(ix); cx.notify(); } } fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext) { cx.emit(DismissEvent); } } impl FluentBuilder for PopupMenu {} impl EventEmitter for PopupMenu {} impl FocusableView for PopupMenu { fn focus_handle(&self, _cx: &gpui::AppContext) -> FocusHandle { self.focus_handle.clone() } } impl Render for PopupMenu { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl gpui::IntoElement { let icon_placeholder = if self.has_icon { Some(Icon::empty()) } else { None }; let has_icon = self.menu_items.iter().any(|item| item.has_icon()); v_flex() .key_context("PopupMenu") .track_focus(&self.focus_handle) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::confirm)) .on_action(cx.listener(Self::dismiss)) .on_mouse_down_out(cx.listener(|this, _, cx| this.dismiss(&Dismiss, cx))) .max_h(self.max_width) .min_w(self.min_width) .p_1() .gap_y_0p5() .bg(cx.theme().menu) .children(self.menu_items.iter_mut().enumerate().map(|(ix, item)| { let this = ListItem::new(("menu-item", ix)) .p_0() .on_click(cx.listener(move |this, _, cx| this.on_click(ix, cx))); match item { PopupMenuItem::Separator => this.disabled(true).child( div() .h(px(1.)) .w_full() .my_px() .border_0() .bg(cx.theme().border), ), PopupMenuItem::Item { icon, label, .. } => { this.py(px(2.)).px_2().rounded_md().text_sm().child( h_flex() .size_full() .items_center() .map(|this| { this.child(div().absolute().text_sm().map(|this| { if let Some(icon) = icon { this.child(icon.clone().small().clone()) } else { this.children(icon_placeholder.clone()) } })) }) .child( div() .when(has_icon, |this| this.pl(px(19.)).pr_2()) .child(label.clone()), ), ) } } })) } }