1use crate::{
2 h_flex, prelude::*, v_flex, Icon, IconName, KeyBinding, Label, List, ListItem, ListSeparator,
3 ListSubHeader,
4};
5use gpui::{
6 px, Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView,
7 IntoElement, Render, Subscription, View, VisualContext,
8};
9use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev};
10use std::{rc::Rc, time::Duration};
11
12enum ContextMenuItem {
13 Separator,
14 Header(SharedString),
15 Entry {
16 label: SharedString,
17 icon: Option<IconName>,
18 handler: Rc<dyn Fn(&mut WindowContext)>,
19 action: Option<Box<dyn Action>>,
20 },
21 CustomEntry {
22 entry_render: Box<dyn Fn(&mut WindowContext) -> AnyElement>,
23 handler: Rc<dyn Fn(&mut WindowContext)>,
24 },
25}
26
27pub struct ContextMenu {
28 items: Vec<ContextMenuItem>,
29 focus_handle: FocusHandle,
30 action_context: Option<FocusHandle>,
31 selected_index: Option<usize>,
32 delayed: bool,
33 clicked: bool,
34 _on_blur_subscription: Subscription,
35}
36
37impl FocusableView for ContextMenu {
38 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
39 self.focus_handle.clone()
40 }
41}
42
43impl EventEmitter<DismissEvent> for ContextMenu {}
44
45impl FluentBuilder for ContextMenu {}
46
47impl ContextMenu {
48 pub fn build(
49 cx: &mut WindowContext,
50 f: impl FnOnce(Self, &mut WindowContext) -> Self,
51 ) -> View<Self> {
52 cx.new_view(|cx| {
53 let focus_handle = cx.focus_handle();
54 let _on_blur_subscription = cx.on_blur(&focus_handle, |this: &mut ContextMenu, cx| {
55 this.cancel(&menu::Cancel, cx)
56 });
57 cx.refresh();
58 f(
59 Self {
60 items: Default::default(),
61 focus_handle,
62 action_context: None,
63 selected_index: None,
64 delayed: false,
65 clicked: false,
66 _on_blur_subscription,
67 },
68 cx,
69 )
70 })
71 }
72
73 pub fn context(mut self, focus: FocusHandle) -> Self {
74 self.action_context = Some(focus);
75 self
76 }
77
78 pub fn header(mut self, title: impl Into<SharedString>) -> Self {
79 self.items.push(ContextMenuItem::Header(title.into()));
80 self
81 }
82
83 pub fn separator(mut self) -> Self {
84 self.items.push(ContextMenuItem::Separator);
85 self
86 }
87
88 pub fn entry(
89 mut self,
90 label: impl Into<SharedString>,
91 action: Option<Box<dyn Action>>,
92 handler: impl Fn(&mut WindowContext) + 'static,
93 ) -> Self {
94 self.items.push(ContextMenuItem::Entry {
95 label: label.into(),
96 handler: Rc::new(handler),
97 icon: None,
98 action,
99 });
100 self
101 }
102
103 pub fn custom_entry(
104 mut self,
105 entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
106 handler: impl Fn(&mut WindowContext) + 'static,
107 ) -> Self {
108 self.items.push(ContextMenuItem::CustomEntry {
109 entry_render: Box::new(entry_render),
110 handler: Rc::new(handler),
111 });
112 self
113 }
114
115 pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
116 self.items.push(ContextMenuItem::Entry {
117 label: label.into(),
118 action: Some(action.boxed_clone()),
119 handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
120 icon: None,
121 });
122 self
123 }
124
125 pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
126 self.items.push(ContextMenuItem::Entry {
127 label: label.into(),
128 action: Some(action.boxed_clone()),
129 handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())),
130 icon: Some(IconName::Link),
131 });
132 self
133 }
134
135 pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
136 match self.selected_index.and_then(|ix| self.items.get(ix)) {
137 Some(
138 ContextMenuItem::Entry { handler, .. }
139 | ContextMenuItem::CustomEntry { handler, .. },
140 ) => (handler)(cx),
141 _ => {}
142 }
143
144 cx.emit(DismissEvent);
145 }
146
147 pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
148 cx.emit(DismissEvent);
149 cx.emit(DismissEvent);
150 }
151
152 fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
153 self.selected_index = self.items.iter().position(|item| item.is_selectable());
154 cx.notify();
155 }
156
157 pub fn select_last(&mut self) -> Option<usize> {
158 for (ix, item) in self.items.iter().enumerate().rev() {
159 if item.is_selectable() {
160 self.selected_index = Some(ix);
161 return Some(ix);
162 }
163 }
164 None
165 }
166
167 fn handle_select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
168 if self.select_last().is_some() {
169 cx.notify();
170 }
171 }
172
173 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
174 if let Some(ix) = self.selected_index {
175 for (ix, item) in self.items.iter().enumerate().skip(ix + 1) {
176 if item.is_selectable() {
177 self.selected_index = Some(ix);
178 cx.notify();
179 break;
180 }
181 }
182 } else {
183 self.select_first(&Default::default(), cx);
184 }
185 }
186
187 pub fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
188 if let Some(ix) = self.selected_index {
189 for (ix, item) in self.items.iter().enumerate().take(ix).rev() {
190 if item.is_selectable() {
191 self.selected_index = Some(ix);
192 cx.notify();
193 break;
194 }
195 }
196 } else {
197 self.handle_select_last(&Default::default(), cx);
198 }
199 }
200
201 pub fn on_action_dispatch(&mut self, dispatched: &Box<dyn Action>, cx: &mut ViewContext<Self>) {
202 if self.clicked {
203 cx.propagate();
204 return;
205 }
206
207 if let Some(ix) = self.items.iter().position(|item| {
208 if let ContextMenuItem::Entry {
209 action: Some(action),
210 ..
211 } = item
212 {
213 action.partial_eq(&**dispatched)
214 } else {
215 false
216 }
217 }) {
218 self.selected_index = Some(ix);
219 self.delayed = true;
220 cx.notify();
221 let action = dispatched.boxed_clone();
222 cx.spawn(|this, mut cx| async move {
223 cx.background_executor()
224 .timer(Duration::from_millis(50))
225 .await;
226 this.update(&mut cx, |this, cx| {
227 cx.dispatch_action(action);
228 this.cancel(&menu::Cancel, cx)
229 })
230 })
231 .detach_and_log_err(cx);
232 } else {
233 cx.propagate()
234 }
235 }
236}
237
238impl ContextMenuItem {
239 fn is_selectable(&self) -> bool {
240 matches!(self, Self::Entry { .. } | Self::CustomEntry { .. })
241 }
242}
243
244impl Render for ContextMenu {
245 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
246 div().elevation_2(cx).flex().flex_row().child(
247 v_flex()
248 .min_w(px(200.))
249 .track_focus(&self.focus_handle)
250 .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
251 .key_context("menu")
252 .on_action(cx.listener(ContextMenu::select_first))
253 .on_action(cx.listener(ContextMenu::handle_select_last))
254 .on_action(cx.listener(ContextMenu::select_next))
255 .on_action(cx.listener(ContextMenu::select_prev))
256 .on_action(cx.listener(ContextMenu::confirm))
257 .on_action(cx.listener(ContextMenu::cancel))
258 .when(!self.delayed, |mut el| {
259 for item in self.items.iter() {
260 if let ContextMenuItem::Entry {
261 action: Some(action),
262 ..
263 } = item
264 {
265 el = el.on_boxed_action(
266 &**action,
267 cx.listener(ContextMenu::on_action_dispatch),
268 );
269 }
270 }
271 el
272 })
273 .flex_none()
274 .child(List::new().children(self.items.iter_mut().enumerate().map(
275 |(ix, item)| match item {
276 ContextMenuItem::Separator => ListSeparator.into_any_element(),
277 ContextMenuItem::Header(header) => {
278 ListSubHeader::new(header.clone()).into_any_element()
279 }
280 ContextMenuItem::Entry {
281 label,
282 handler,
283 icon,
284 action,
285 } => {
286 let handler = handler.clone();
287 let menu = cx.view().downgrade();
288
289 let label_element = if let Some(icon) = icon {
290 h_flex()
291 .gap_1()
292 .child(Label::new(label.clone()))
293 .child(Icon::new(*icon))
294 .into_any_element()
295 } else {
296 Label::new(label.clone()).into_any_element()
297 };
298
299 ListItem::new(ix)
300 .inset(true)
301 .selected(Some(ix) == self.selected_index)
302 .on_click(move |_, cx| {
303 handler(cx);
304 menu.update(cx, |menu, cx| {
305 menu.clicked = true;
306 cx.emit(DismissEvent);
307 })
308 .ok();
309 })
310 .child(
311 h_flex()
312 .w_full()
313 .justify_between()
314 .child(label_element)
315 .debug_selector(|| format!("MENU_ITEM-{}", label))
316 .children(action.as_ref().and_then(|action| {
317 self.action_context
318 .as_ref()
319 .map(|focus| {
320 KeyBinding::for_action_in(&**action, focus, cx)
321 })
322 .unwrap_or_else(|| {
323 KeyBinding::for_action(&**action, cx)
324 })
325 .map(|binding| div().ml_1().child(binding))
326 })),
327 )
328 .into_any_element()
329 }
330 ContextMenuItem::CustomEntry {
331 entry_render,
332 handler,
333 } => {
334 let handler = handler.clone();
335 let menu = cx.view().downgrade();
336 ListItem::new(ix)
337 .inset(true)
338 .selected(Some(ix) == self.selected_index)
339 .on_click(move |_, cx| {
340 handler(cx);
341 menu.update(cx, |menu, cx| {
342 menu.clicked = true;
343 cx.emit(DismissEvent);
344 })
345 .ok();
346 })
347 .child(entry_render(cx))
348 .into_any_element()
349 }
350 },
351 ))),
352 )
353 }
354}