1use std::{
2 cell::{Cell, RefCell},
3 rc::Rc,
4};
5
6use gpui::{
7 Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, FocusHandle,
8 Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement,
9 IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
10 StyleRefinement, Styled, Subscription, Window, anchored, deferred, div, prelude::FluentBuilder,
11 px,
12};
13
14use crate::menu::PopupMenu;
15
16pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled {
18 #[track_caller]
23 fn context_menu(
24 mut self,
25 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
26 ) -> ContextMenu<Self>
27 where
28 Self: Sized,
29 {
30 let caller = std::panic::Location::caller();
33 let id = self
34 .interactivity()
35 .element_id
36 .clone()
37 .map(|id| ElementId::Name(format!("context-menu-{:?}", id).into()))
38 .unwrap_or_else(|| ElementId::CodeLocation(*caller));
39 ContextMenu::new(id, self).menu(f)
40 }
41}
42
43impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
44
45pub struct ContextMenu<E: ParentElement + Styled + Sized> {
47 id: ElementId,
48 element: Option<E>,
49 menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
50 _ignore_style: StyleRefinement,
52 anchor: Anchor,
53}
54
55impl<E: ParentElement + Styled> ContextMenu<E> {
56 pub fn new(id: impl Into<ElementId>, element: E) -> Self {
58 Self {
59 id: id.into(),
60 element: Some(element),
61 menu: None,
62 anchor: Anchor::TopLeft,
63 _ignore_style: StyleRefinement::default(),
64 }
65 }
66
67 #[must_use]
69 fn menu<F>(mut self, builder: F) -> Self
70 where
71 F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
72 {
73 self.menu = Some(Rc::new(builder));
74 self
75 }
76
77 fn with_element_state<R>(
78 &mut self,
79 id: &GlobalElementId,
80 window: &mut Window,
81 cx: &mut App,
82 f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
83 ) -> R {
84 window.with_optional_element_state::<ContextMenuState, _>(
85 Some(id),
86 |element_state, window| {
87 let mut element_state = element_state.unwrap().unwrap_or_default();
88 let result = f(self, &mut element_state, window, cx);
89 (result, Some(element_state))
90 },
91 )
92 }
93}
94
95impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
96 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
97 if let Some(element) = &mut self.element {
98 element.extend(elements);
99 }
100 }
101}
102
103impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
104 fn style(&mut self) -> &mut StyleRefinement {
105 if let Some(element) = &mut self.element {
106 element.style()
107 } else {
108 &mut self._ignore_style
109 }
110 }
111}
112
113impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
114 type Element = Self;
115
116 fn into_element(self) -> Self::Element {
117 self
118 }
119}
120
121struct ContextMenuSharedState {
122 menu_view: Option<Entity<PopupMenu>>,
123 open: bool,
124 position: Point<Pixels>,
125 trigger_focus_handle: Option<FocusHandle>,
130 _subscription: Option<Subscription>,
131}
132
133pub struct ContextMenuState {
134 element: Option<AnyElement>,
135 draws_menu: Rc<Cell<bool>>,
143 shared_state: Rc<RefCell<ContextMenuSharedState>>,
144}
145
146impl Default for ContextMenuState {
147 fn default() -> Self {
148 Self {
149 element: None,
150 draws_menu: Rc::default(),
151 shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
152 menu_view: None,
153 open: false,
154 position: Default::default(),
155 trigger_focus_handle: None,
156 _subscription: None,
157 })),
158 }
159 }
160}
161
162struct DeferredMenu {
165 draws: Rc<Cell<bool>>,
166 menu: Option<AnyElement>,
167}
168
169impl IntoElement for DeferredMenu {
170 type Element = Self;
171
172 fn into_element(self) -> Self::Element {
173 self
174 }
175}
176
177impl Element for DeferredMenu {
178 type RequestLayoutState = ();
179 type PrepaintState = ();
180
181 fn id(&self) -> Option<ElementId> {
182 None
183 }
184
185 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
186 None
187 }
188
189 fn request_layout(
190 &mut self,
191 _: Option<&GlobalElementId>,
192 _: Option<&InspectorElementId>,
193 window: &mut Window,
194 cx: &mut App,
195 ) -> (LayoutId, ()) {
196 let menu = self.menu.as_mut().expect("menu should exist");
197 (menu.request_layout(window, cx), ())
198 }
199
200 fn prepaint(
201 &mut self,
202 _: Option<&GlobalElementId>,
203 _: Option<&InspectorElementId>,
204 _: gpui::Bounds<Pixels>,
205 _: &mut (),
206 window: &mut Window,
207 cx: &mut App,
208 ) {
209 if !self.draws.get() {
210 return;
211 }
212 if let Some(menu) = &mut self.menu {
213 menu.prepaint(window, cx);
214 }
215 }
216
217 fn paint(
218 &mut self,
219 _: Option<&GlobalElementId>,
220 _: Option<&InspectorElementId>,
221 _: gpui::Bounds<Pixels>,
222 _: &mut (),
223 _: &mut (),
224 window: &mut Window,
225 cx: &mut App,
226 ) {
227 if !self.draws.get() {
228 return;
229 }
230 if let Some(menu) = &mut self.menu {
231 menu.paint(window, cx);
232 }
233 }
234}
235
236impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
237 type RequestLayoutState = ContextMenuState;
238 type PrepaintState = Hitbox;
239
240 fn id(&self) -> Option<ElementId> {
241 Some(self.id.clone())
242 }
243
244 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
245 None
246 }
247
248 fn request_layout(
249 &mut self,
250 id: Option<&gpui::GlobalElementId>,
251 _: Option<&gpui::InspectorElementId>,
252 window: &mut Window,
253 cx: &mut App,
254 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
255 let anchor = self.anchor;
256
257 self.with_element_state(
258 id.unwrap(),
259 window,
260 cx,
261 |this, state: &mut ContextMenuState, window, cx| {
262 let (position, open) = {
263 let shared_state = state.shared_state.borrow();
264 (shared_state.position, shared_state.open)
265 };
266 state
267 .shared_state
268 .borrow_mut()
269 .trigger_focus_handle
270 .get_or_insert_with(|| cx.focus_handle());
271 let menu_view = state.shared_state.borrow().menu_view.clone();
272 let draws_menu = Rc::new(Cell::new(false));
273 let mut menu_element = None;
274 if open {
275 let has_menu_item = menu_view
276 .as_ref()
277 .map(|menu| !menu.read(cx).is_empty())
278 .unwrap_or(false);
279
280 if has_menu_item {
281 menu_element = Some(
282 deferred(
283 anchored().child(
284 div()
285 .w(window.bounds().size.width)
286 .h(window.bounds().size.height)
287 .on_scroll_wheel(|_, _, cx| {
288 cx.stop_propagation();
289 })
290 .child(
291 anchored()
292 .position(position)
293 .snap_to_window_with_margin(px(8.))
294 .anchor(anchor)
295 .when_some(menu_view, |this, menu| {
296 if !menu
298 .focus_handle(cx)
299 .contains_focused(window, cx)
300 {
301 menu.focus_handle(cx).focus(window, cx);
302 }
303
304 this.child(menu.clone())
305 }),
306 ),
307 ),
308 )
309 .with_priority(gpui_base::POPUP_PRIORITY)
310 .into_any(),
311 );
312 }
313 }
314 let menu_element = menu_element.map(|menu| DeferredMenu {
315 draws: draws_menu.clone(),
316 menu: Some(menu),
317 });
318
319 let mut element = this
320 .element
321 .take()
322 .expect("Element should exists.")
323 .children(menu_element)
324 .into_any_element();
325
326 let layout_id = element.request_layout(window, cx);
327
328 (
329 layout_id,
330 ContextMenuState {
331 element: Some(element),
332 draws_menu,
333 shared_state: state.shared_state.clone(),
334 },
335 )
336 },
337 )
338 }
339
340 fn prepaint(
341 &mut self,
342 _: Option<&gpui::GlobalElementId>,
343 _: Option<&InspectorElementId>,
344 bounds: gpui::Bounds<gpui::Pixels>,
345 request_layout: &mut Self::RequestLayoutState,
346 window: &mut Window,
347 cx: &mut App,
348 ) -> Self::PrepaintState {
349 if let Some(trigger_focus) = request_layout
350 .shared_state
351 .borrow()
352 .trigger_focus_handle
353 .as_ref()
354 {
355 window.set_focus_handle(trigger_focus, cx);
356 }
357 let position = request_layout.shared_state.borrow().position;
358 request_layout.draws_menu.set(bounds.contains(&position));
359 if let Some(element) = &mut request_layout.element {
360 element.prepaint(window, cx);
361 }
362 window.insert_hitbox(bounds, HitboxBehavior::Normal)
363 }
364
365 fn paint(
366 &mut self,
367 id: Option<&gpui::GlobalElementId>,
368 _: Option<&InspectorElementId>,
369 _: gpui::Bounds<gpui::Pixels>,
370 request_layout: &mut Self::RequestLayoutState,
371 hitbox: &mut Self::PrepaintState,
372 window: &mut Window,
373 cx: &mut App,
374 ) {
375 if let Some(element) = &mut request_layout.element {
376 element.paint(window, cx);
377 }
378
379 let builder = self.menu.clone();
381
382 self.with_element_state(
383 id.unwrap(),
384 window,
385 cx,
386 |_view, state: &mut ContextMenuState, window, _| {
387 let shared_state = state.shared_state.clone();
388
389 let hitbox = hitbox.clone();
390 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
392 if phase.bubble()
393 && event.button == MouseButton::Right
394 && hitbox.is_hovered(window)
395 {
396 let previous_focus_handle = window.focused(cx).and_then(|focused| {
399 let shared_state = shared_state.borrow();
400 match shared_state.menu_view.as_ref() {
401 Some(menu) if menu.read(cx).focus_handle == focused => {
402 menu.read(cx).previous_focus_handle.clone()
403 }
404 _ => Some(focused),
405 }
406 });
407
408 {
409 let mut shared_state = shared_state.borrow_mut();
410 shared_state.menu_view = None;
413 shared_state._subscription = None;
414 shared_state.position = event.position;
415 shared_state.open = true;
416 }
417
418 window.defer(cx, {
420 let shared_state = shared_state.clone();
421 let builder = builder.clone();
422 move |window, cx| {
423 let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
424 let Some(build) = &builder else {
425 return menu;
426 };
427 build(menu, window, cx)
428 });
429 let trigger_focus_handle =
430 shared_state.borrow().trigger_focus_handle.clone();
431 menu.update(cx, |menu, cx| {
432 menu.set_trigger_focus(trigger_focus_handle, cx);
433 menu.set_previous_focus(previous_focus_handle, cx);
434 });
435
436 let _subscription = window.subscribe(&menu, cx, {
438 let shared_state = shared_state.clone();
439 move |_, _: &DismissEvent, window, _cx| {
440 shared_state.borrow_mut().open = false;
441 window.refresh();
442 }
443 });
444
445 {
447 let mut state = shared_state.borrow_mut();
448 state.menu_view = Some(menu.clone());
449 state._subscription = Some(_subscription);
450 window.refresh();
451 }
452 }
453 });
454 }
455 });
456 },
457 );
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::menu::PopupMenuItem;
465 use crate::theme::Theme;
466 use gpui::{
467 Context, FocusHandle, IntoElement, KeyBinding, Render, TestAppContext, VisualTestContext,
468 actions, point, px,
469 };
470 use std::cell::Cell;
471
472 actions!(context_menu_test, [RemoveTab, CopyText]);
473
474 struct TestRoot {
478 content_focus: FocusHandle,
479 received: Rc<Cell<bool>>,
480 }
481
482 impl Render for TestRoot {
483 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
484 let received = self.received.clone();
485 div()
486 .size_full()
487 .child(
488 div()
489 .id("content")
490 .h(px(40.))
491 .track_focus(&self.content_focus),
492 )
493 .child(
494 div()
495 .id("action-bar")
496 .h(px(60.))
497 .on_action(move |_: &RemoveTab, _, _| received.set(true))
498 .child(
499 div()
500 .id("tab")
501 .size_full()
502 .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
503 ),
504 )
505 }
506 }
507
508 #[gpui::test]
509 fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
510 cx.update(|cx| {
511 cx.set_global(Theme::default());
512 super::super::popup_menu::init(cx);
513 });
514
515 let received = Rc::new(Cell::new(false));
516 let (root, cx) = cx.add_window_view({
517 let received = received.clone();
518 move |window, cx| {
519 let content_focus = cx.focus_handle();
520 content_focus.focus(window, cx);
521 TestRoot {
522 content_focus,
523 received,
524 }
525 }
526 });
527 let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
528 let cx: &mut VisualTestContext = cx;
529 cx.run_until_parked();
530 cx.update(|window, cx| {
531 _ = window.draw(cx);
532 });
533
534 cx.simulate_event(MouseDownEvent {
536 button: MouseButton::Right,
537 position: point(px(50.), px(70.)),
538 modifiers: Default::default(),
539 click_count: 1,
540 first_mouse: false,
541 });
542 cx.run_until_parked();
545 cx.update(|window, cx| {
546 _ = window.draw(cx);
547 });
548
549 cx.simulate_keystrokes("down enter");
552 cx.run_until_parked();
553
554 assert!(received.get());
557 cx.update(|window, cx| {
560 assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
561 });
562 }
563
564 const CONTEXT: &str = "context_menu_test";
565
566 struct UnfocusedRoot {
570 frames: Rc<Cell<usize>>,
571 }
572
573 impl Render for UnfocusedRoot {
574 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
575 self.frames.set(self.frames.get() + 1);
576 div()
577 .size_full()
578 .child(
579 div()
580 .key_context(CONTEXT)
581 .on_action(|_: &CopyText, _, _| {})
582 .child(
583 div()
584 .id("tab")
585 .w(px(100.))
586 .h(px(30.))
587 .context_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
588 ),
589 )
590 .child(div().child("Status"))
591 }
592 }
593
594 struct RowsRoot {
597 clicked: Rc<Cell<usize>>,
598 }
599
600 impl Render for RowsRoot {
601 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
602 div().size_full().children((0..3).map(|_| {
603 let clicked = self.clicked.clone();
604 div()
605 .w(px(100.))
606 .h(px(30.))
607 .context_menu(move |menu, _, _| {
608 let clicked = clicked.clone();
609 menu.item(
610 PopupMenuItem::new("Favorite")
611 .on_click(move |_, _, _| clicked.set(clicked.get() + 1)),
612 )
613 })
614 }))
615 }
616 }
617
618 #[gpui::test]
619 fn item_click_fires_once_from_rows_without_an_id(cx: &mut TestAppContext) {
620 cx.update(|cx| crate::init(cx));
621 let clicked = Rc::new(Cell::new(0));
622 let (_, cx) = cx.add_window_view({
623 let clicked = clicked.clone();
624 move |_, _| RowsRoot { clicked }
625 });
626 cx.update(|window, cx| {
627 window.draw(cx).clear(cx);
628 });
629
630 let press = point(px(10.), px(40.));
632 cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
633 cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
634 cx.run_until_parked();
635 cx.update(|window, cx| {
636 window.draw(cx).clear(cx);
637 });
638
639 let item = point(press.x + px(30.), press.y + px(17.));
641 cx.simulate_mouse_move(item, None, Default::default());
642 cx.simulate_click(item, Default::default());
643 cx.run_until_parked();
644
645 assert_eq!(
646 clicked.get(),
647 1,
648 "the item's on_click must fire exactly once"
649 );
650 }
651
652 #[gpui::test]
653 fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
654 cx.update(|cx| {
655 crate::init(cx);
656 cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
657 });
658 let frames = Rc::new(Cell::new(0));
659 let (_, cx) = cx.add_window_view({
660 let frames = frames.clone();
661 move |_, _| UnfocusedRoot { frames }
662 });
663 cx.update(|window, cx| {
664 window.draw(cx).clear(cx);
665 assert!(window.focused(cx).is_none());
666 });
667 let frames_before_open = frames.get();
668
669 cx.simulate_mouse_down(
672 point(px(10.), px(10.)),
673 MouseButton::Right,
674 Default::default(),
675 );
676
677 assert_eq!(
678 frames.get(),
679 frames_before_open + 1,
680 "the press must be followed by exactly one frame for this to test the first one"
681 );
682 assert!(
683 cx.debug_bounds("kbd:ctrl-c").is_some(),
684 "the shortcut hint must be painted on the same frame as its item"
685 );
686 }
687}