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