1use std::{
2 cell::{Cell, RefCell},
3 rc::Rc,
4};
5
6use gpui::{
7 Anchor, AnyElement, App, Bounds, Context, DismissEvent, Element, ElementId, Entity,
8 FocusHandle, Focusable, GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId,
9 InteractiveElement, IntoElement, LayoutId, MouseButton, MouseDownEvent, ParentElement, Pixels,
10 Point, Position, Style, StyleRefinement, Styled, Subscription, Window, anchored, deferred, div,
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 {
168 draws: Rc<Cell<bool>>,
169 menu_view: Entity<PopupMenu>,
170 position: Point<Pixels>,
171 anchor: Anchor,
172 menu: Option<AnyElement>,
173}
174
175impl DeferredMenu {
176 fn build_menu(&self, window: &mut Window, cx: &mut App) -> AnyElement {
177 let focus_handle = self.menu_view.focus_handle(cx);
179 if !focus_handle.contains_focused(window, cx) {
180 focus_handle.focus(window, cx);
181 }
182
183 deferred(
184 anchored().child(
185 div()
186 .w(window.bounds().size.width)
187 .h(window.bounds().size.height)
188 .on_scroll_wheel(|_, _, cx| {
189 cx.stop_propagation();
190 })
191 .child(
192 anchored()
193 .position(self.position)
194 .snap_to_window_with_margin(px(8.))
195 .anchor(self.anchor)
196 .child(self.menu_view.clone()),
197 ),
198 ),
199 )
200 .with_priority(gpui_base::POPUP_PRIORITY)
201 .into_any()
202 }
203}
204
205impl IntoElement for DeferredMenu {
206 type Element = Self;
207
208 fn into_element(self) -> Self::Element {
209 self
210 }
211}
212
213impl Element for DeferredMenu {
214 type RequestLayoutState = ();
215 type PrepaintState = ();
216
217 fn id(&self) -> Option<ElementId> {
218 None
219 }
220
221 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
222 None
223 }
224
225 fn request_layout(
226 &mut self,
227 _: Option<&GlobalElementId>,
228 _: Option<&InspectorElementId>,
229 window: &mut Window,
230 cx: &mut App,
231 ) -> (LayoutId, ()) {
232 let style = Style {
235 position: Position::Absolute,
236 ..Style::default()
237 };
238 (window.request_layout(style, None, cx), ())
239 }
240
241 fn prepaint(
242 &mut self,
243 _: Option<&GlobalElementId>,
244 _: Option<&InspectorElementId>,
245 bounds: Bounds<Pixels>,
246 _: &mut (),
247 window: &mut Window,
248 cx: &mut App,
249 ) {
250 if !self.draws.get() {
251 return;
252 }
253 let mut menu = self.build_menu(window, cx);
254 menu.prepaint_as_root(bounds.origin, window.viewport_size().into(), window, cx);
255 self.menu = Some(menu);
256 }
257
258 fn paint(
259 &mut self,
260 _: Option<&GlobalElementId>,
261 _: Option<&InspectorElementId>,
262 _: Bounds<Pixels>,
263 _: &mut (),
264 _: &mut (),
265 window: &mut Window,
266 cx: &mut App,
267 ) {
268 if let Some(menu) = &mut self.menu {
269 menu.paint(window, cx);
270 }
271 }
272}
273
274impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
275 type RequestLayoutState = ContextMenuState;
276 type PrepaintState = Hitbox;
277
278 fn id(&self) -> Option<ElementId> {
279 Some(self.id.clone())
280 }
281
282 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
283 None
284 }
285
286 fn request_layout(
287 &mut self,
288 id: Option<&gpui::GlobalElementId>,
289 _: Option<&gpui::InspectorElementId>,
290 window: &mut Window,
291 cx: &mut App,
292 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
293 let anchor = self.anchor;
294
295 self.with_element_state(
296 id.unwrap(),
297 window,
298 cx,
299 |this, state: &mut ContextMenuState, window, cx| {
300 let (position, open) = {
301 let shared_state = state.shared_state.borrow();
302 (shared_state.position, shared_state.open)
303 };
304 state
305 .shared_state
306 .borrow_mut()
307 .trigger_focus_handle
308 .get_or_insert_with(|| cx.focus_handle());
309 let menu_view = state.shared_state.borrow().menu_view.clone();
310 let draws_menu = Rc::new(Cell::new(false));
311 let menu_element = menu_view
312 .filter(|menu| open && !menu.read(cx).is_empty())
313 .map(|menu_view| DeferredMenu {
314 draws: draws_menu.clone(),
315 menu_view,
316 position,
317 anchor,
318 menu: None,
319 });
320
321 let mut element = this
322 .element
323 .take()
324 .expect("Element should exists.")
325 .children(menu_element)
326 .into_any_element();
327
328 let layout_id = element.request_layout(window, cx);
329
330 (
331 layout_id,
332 ContextMenuState {
333 element: Some(element),
334 draws_menu,
335 shared_state: state.shared_state.clone(),
336 },
337 )
338 },
339 )
340 }
341
342 fn prepaint(
343 &mut self,
344 _: Option<&gpui::GlobalElementId>,
345 _: Option<&InspectorElementId>,
346 bounds: gpui::Bounds<gpui::Pixels>,
347 request_layout: &mut Self::RequestLayoutState,
348 window: &mut Window,
349 cx: &mut App,
350 ) -> Self::PrepaintState {
351 if let Some(trigger_focus) = request_layout
352 .shared_state
353 .borrow()
354 .trigger_focus_handle
355 .as_ref()
356 {
357 window.set_focus_handle(trigger_focus, cx);
358 }
359 let position = request_layout.shared_state.borrow().position;
360 request_layout.draws_menu.set(bounds.contains(&position));
361 if let Some(element) = &mut request_layout.element {
362 element.prepaint(window, cx);
363 }
364 window.insert_hitbox(bounds, HitboxBehavior::Normal)
365 }
366
367 fn paint(
368 &mut self,
369 id: Option<&gpui::GlobalElementId>,
370 _: Option<&InspectorElementId>,
371 _: gpui::Bounds<gpui::Pixels>,
372 request_layout: &mut Self::RequestLayoutState,
373 hitbox: &mut Self::PrepaintState,
374 window: &mut Window,
375 cx: &mut App,
376 ) {
377 if let Some(element) = &mut request_layout.element {
378 element.paint(window, cx);
379 }
380
381 let builder = self.menu.clone();
383
384 self.with_element_state(
385 id.unwrap(),
386 window,
387 cx,
388 |_view, state: &mut ContextMenuState, window, _| {
389 let shared_state = state.shared_state.clone();
390
391 let hitbox = hitbox.clone();
392 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
394 if phase.bubble()
395 && event.button == MouseButton::Right
396 && hitbox.is_hovered(window)
397 {
398 let previous_focus_handle = window.focused(cx).and_then(|focused| {
401 let shared_state = shared_state.borrow();
402 match shared_state.menu_view.as_ref() {
403 Some(menu) if menu.read(cx).focus_handle == focused => {
404 menu.read(cx).previous_focus_handle.clone()
405 }
406 _ => Some(focused),
407 }
408 });
409
410 {
411 let mut shared_state = shared_state.borrow_mut();
412 shared_state.menu_view = None;
415 shared_state._subscription = None;
416 shared_state.position = event.position;
417 shared_state.open = true;
418 }
419
420 window.defer(cx, {
422 let shared_state = shared_state.clone();
423 let builder = builder.clone();
424 move |window, cx| {
425 let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
426 let Some(build) = &builder else {
427 return menu;
428 };
429 build(menu, window, cx)
430 });
431 let trigger_focus_handle =
432 shared_state.borrow().trigger_focus_handle.clone();
433 menu.update(cx, |menu, cx| {
434 menu.set_trigger_focus(trigger_focus_handle, cx);
435 menu.set_previous_focus(previous_focus_handle, cx);
436 });
437
438 let _subscription = window.subscribe(&menu, cx, {
445 let shared_state = Rc::downgrade(&shared_state);
446 move |_, _: &DismissEvent, window, _cx| {
447 if let Some(shared_state) = shared_state.upgrade() {
448 shared_state.borrow_mut().open = false;
449 window.refresh();
450 }
451 }
452 });
453
454 {
456 let mut state = shared_state.borrow_mut();
457 state.menu_view = Some(menu.clone());
458 state._subscription = Some(_subscription);
459 window.refresh();
460 }
461 }
462 });
463 }
464 });
465 },
466 );
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::menu::PopupMenuItem;
474 use crate::theme::Theme;
475 use gpui::{
476 Context, FocusHandle, IntoElement, KeyBinding, Render, TestAppContext, VisualTestContext,
477 actions, point, px,
478 };
479 use std::cell::Cell;
480
481 actions!(context_menu_test, [RemoveTab, CopyText]);
482
483 struct TestRoot {
487 content_focus: FocusHandle,
488 received: Rc<Cell<bool>>,
489 }
490
491 impl Render for TestRoot {
492 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
493 let received = self.received.clone();
494 div()
495 .size_full()
496 .child(
497 div()
498 .id("content")
499 .h(px(40.))
500 .track_focus(&self.content_focus),
501 )
502 .child(
503 div()
504 .id("action-bar")
505 .h(px(60.))
506 .on_action(move |_: &RemoveTab, _, _| received.set(true))
507 .child(
508 div()
509 .id("tab")
510 .size_full()
511 .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
512 ),
513 )
514 }
515 }
516
517 #[gpui::test]
518 fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
519 cx.update(|cx| {
520 cx.set_global(Theme::default());
521 super::super::popup_menu::init(cx);
522 });
523
524 let received = Rc::new(Cell::new(false));
525 let (root, cx) = cx.add_window_view({
526 let received = received.clone();
527 move |window, cx| {
528 let content_focus = cx.focus_handle();
529 content_focus.focus(window, cx);
530 TestRoot {
531 content_focus,
532 received,
533 }
534 }
535 });
536 let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
537 let cx: &mut VisualTestContext = cx;
538 cx.run_until_parked();
539 cx.update(|window, cx| {
540 _ = window.draw(cx);
541 });
542
543 cx.simulate_event(MouseDownEvent {
545 button: MouseButton::Right,
546 position: point(px(50.), px(70.)),
547 modifiers: Default::default(),
548 click_count: 1,
549 first_mouse: false,
550 });
551 cx.run_until_parked();
554 cx.update(|window, cx| {
555 _ = window.draw(cx);
556 });
557
558 cx.simulate_keystrokes("down enter");
561 cx.run_until_parked();
562
563 assert!(received.get());
566 cx.update(|window, cx| {
569 assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
570 });
571 }
572
573 const CONTEXT: &str = "context_menu_test";
574
575 struct UnfocusedRoot {
579 frames: Rc<Cell<usize>>,
580 }
581
582 impl Render for UnfocusedRoot {
583 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
584 self.frames.set(self.frames.get() + 1);
585 div()
586 .size_full()
587 .child(
588 div()
589 .key_context(CONTEXT)
590 .on_action(|_: &CopyText, _, _| {})
591 .child(
592 div()
593 .id("tab")
594 .w(px(100.))
595 .h(px(30.))
596 .context_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
597 ),
598 )
599 .child(div().child("Status"))
600 }
601 }
602
603 struct RowsRoot {
606 clicked: Rc<Cell<usize>>,
607 }
608
609 impl Render for RowsRoot {
610 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
611 div().size_full().children((0..3).map(|_| {
612 let clicked = self.clicked.clone();
613 div()
614 .w(px(100.))
615 .h(px(30.))
616 .context_menu(move |menu, _, _| {
617 let clicked = clicked.clone();
618 menu.item(
619 PopupMenuItem::new("Favorite")
620 .on_click(move |_, _, _| clicked.set(clicked.get() + 1)),
621 )
622 })
623 }))
624 }
625 }
626
627 #[gpui::test]
628 fn item_click_fires_once_from_rows_without_an_id(cx: &mut TestAppContext) {
629 cx.update(|cx| crate::init(cx));
630 let clicked = Rc::new(Cell::new(0));
631 let (_, cx) = cx.add_window_view({
632 let clicked = clicked.clone();
633 move |_, _| RowsRoot { clicked }
634 });
635 cx.update(|window, cx| {
636 window.draw(cx).clear(cx);
637 });
638
639 let press = point(px(10.), px(40.));
641 cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
642 cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
643 cx.run_until_parked();
644 cx.update(|window, cx| {
645 window.draw(cx).clear(cx);
646 });
647
648 let item = point(press.x + px(30.), press.y + px(17.));
650 cx.simulate_mouse_move(item, None, Default::default());
651 cx.simulate_click(item, Default::default());
652 cx.run_until_parked();
653
654 assert_eq!(
655 clicked.get(),
656 1,
657 "the item's on_click must fire exactly once"
658 );
659 }
660
661 #[gpui::test]
668 fn open_without_dismiss_releases_the_menu_entity(cx: &mut TestAppContext) {
669 cx.update(|cx| crate::init(cx));
670 let before = cx.update(|cx| cx.leak_detector_snapshot());
671
672 {
673 let (_, cx) = cx.add_window_view(|_, _| RowsRoot {
674 clicked: Rc::new(Cell::new(0)),
675 });
676 cx.update(|window, cx| {
677 window.draw(cx).clear(cx);
678 });
679
680 let press = point(px(10.), px(10.));
682 cx.simulate_mouse_down(press, MouseButton::Right, Default::default());
683 cx.simulate_mouse_up(press, MouseButton::Right, Default::default());
684 cx.run_until_parked();
685 cx.update(|window, cx| {
686 window.draw(cx).clear(cx);
687 });
688
689 cx.update(|window, _| window.remove_window());
691 cx.run_until_parked();
692 }
693
694 cx.update(|cx| cx.assert_no_new_leaks(&before));
698 }
699
700 #[gpui::test]
701 fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
702 cx.update(|cx| {
703 crate::init(cx);
704 cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
705 });
706 let frames = Rc::new(Cell::new(0));
707 let (_, cx) = cx.add_window_view({
708 let frames = frames.clone();
709 move |_, _| UnfocusedRoot { frames }
710 });
711 cx.update(|window, cx| {
712 window.draw(cx).clear(cx);
713 assert!(window.focused(cx).is_none());
714 });
715 let frames_before_open = frames.get();
716
717 cx.simulate_mouse_down(
720 point(px(10.), px(10.)),
721 MouseButton::Right,
722 Default::default(),
723 );
724
725 assert_eq!(
726 frames.get(),
727 frames_before_open + 1,
728 "the press must be followed by exactly one frame for this to test the first one"
729 );
730 assert!(
731 cx.debug_bounds("kbd:ctrl-c").is_some(),
732 "the shortcut hint must be painted on the same frame as its item"
733 );
734 }
735}