1use std::{cell::RefCell, rc::Rc};
2
3use gpui::{
4 Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, Focusable,
5 GlobalElementId, Hitbox, HitboxBehavior, InspectorElementId, InteractiveElement, IntoElement,
6 MouseButton, MouseDownEvent, ParentElement, Pixels, Point, StyleRefinement, Styled,
7 Subscription, Window, anchored, deferred, div, prelude::FluentBuilder, px,
8};
9
10use crate::menu::PopupMenu;
11
12pub trait ContextMenuExt: InteractiveElement + ParentElement + Styled {
14 #[track_caller]
19 fn context_menu(
20 mut self,
21 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
22 ) -> ContextMenu<Self>
23 where
24 Self: Sized,
25 {
26 let caller = std::panic::Location::caller();
29 let id = self
30 .interactivity()
31 .element_id
32 .clone()
33 .map(|id| ElementId::Name(format!("context-menu-{:?}", id).into()))
34 .unwrap_or_else(|| ElementId::CodeLocation(*caller));
35 ContextMenu::new(id, self).menu(f)
36 }
37}
38
39impl<E: InteractiveElement + ParentElement + Styled> ContextMenuExt for E {}
40
41pub struct ContextMenu<E: ParentElement + Styled + Sized> {
43 id: ElementId,
44 element: Option<E>,
45 menu: Option<Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>>,
46 _ignore_style: StyleRefinement,
48 anchor: Anchor,
49}
50
51impl<E: ParentElement + Styled> ContextMenu<E> {
52 pub fn new(id: impl Into<ElementId>, element: E) -> Self {
54 Self {
55 id: id.into(),
56 element: Some(element),
57 menu: None,
58 anchor: Anchor::TopLeft,
59 _ignore_style: StyleRefinement::default(),
60 }
61 }
62
63 #[must_use]
65 fn menu<F>(mut self, builder: F) -> Self
66 where
67 F: Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
68 {
69 self.menu = Some(Rc::new(builder));
70 self
71 }
72
73 fn with_element_state<R>(
74 &mut self,
75 id: &GlobalElementId,
76 window: &mut Window,
77 cx: &mut App,
78 f: impl FnOnce(&mut Self, &mut ContextMenuState, &mut Window, &mut App) -> R,
79 ) -> R {
80 window.with_optional_element_state::<ContextMenuState, _>(
81 Some(id),
82 |element_state, window| {
83 let mut element_state = element_state.unwrap().unwrap_or_default();
84 let result = f(self, &mut element_state, window, cx);
85 (result, Some(element_state))
86 },
87 )
88 }
89}
90
91impl<E: ParentElement + Styled> ParentElement for ContextMenu<E> {
92 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
93 if let Some(element) = &mut self.element {
94 element.extend(elements);
95 }
96 }
97}
98
99impl<E: ParentElement + Styled> Styled for ContextMenu<E> {
100 fn style(&mut self) -> &mut StyleRefinement {
101 if let Some(element) = &mut self.element {
102 element.style()
103 } else {
104 &mut self._ignore_style
105 }
106 }
107}
108
109impl<E: ParentElement + Styled + IntoElement + 'static> IntoElement for ContextMenu<E> {
110 type Element = Self;
111
112 fn into_element(self) -> Self::Element {
113 self
114 }
115}
116
117struct ContextMenuSharedState {
118 menu_view: Option<Entity<PopupMenu>>,
119 open: bool,
120 position: Point<Pixels>,
121 _subscription: Option<Subscription>,
122}
123
124pub struct ContextMenuState {
125 element: Option<AnyElement>,
126 shared_state: Rc<RefCell<ContextMenuSharedState>>,
127}
128
129impl Default for ContextMenuState {
130 fn default() -> Self {
131 Self {
132 element: None,
133 shared_state: Rc::new(RefCell::new(ContextMenuSharedState {
134 menu_view: None,
135 open: false,
136 position: Default::default(),
137 _subscription: None,
138 })),
139 }
140 }
141}
142
143impl<E: ParentElement + Styled + IntoElement + 'static> Element for ContextMenu<E> {
144 type RequestLayoutState = ContextMenuState;
145 type PrepaintState = Hitbox;
146
147 fn id(&self) -> Option<ElementId> {
148 Some(self.id.clone())
149 }
150
151 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
152 None
153 }
154
155 fn request_layout(
156 &mut self,
157 id: Option<&gpui::GlobalElementId>,
158 _: Option<&gpui::InspectorElementId>,
159 window: &mut Window,
160 cx: &mut App,
161 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
162 let anchor = self.anchor;
163
164 self.with_element_state(
165 id.unwrap(),
166 window,
167 cx,
168 |this, state: &mut ContextMenuState, window, cx| {
169 let (position, open) = {
170 let shared_state = state.shared_state.borrow();
171 (shared_state.position, shared_state.open)
172 };
173 let menu_view = state.shared_state.borrow().menu_view.clone();
174 let mut menu_element = None;
175 if open {
176 let has_menu_item = menu_view
177 .as_ref()
178 .map(|menu| !menu.read(cx).is_empty())
179 .unwrap_or(false);
180
181 if has_menu_item {
182 menu_element = Some(
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(position)
194 .snap_to_window_with_margin(px(8.))
195 .anchor(anchor)
196 .when_some(menu_view, |this, menu| {
197 if !menu
199 .focus_handle(cx)
200 .contains_focused(window, cx)
201 {
202 menu.focus_handle(cx).focus(window, cx);
203 }
204
205 this.child(menu.clone())
206 }),
207 ),
208 ),
209 )
210 .with_priority(gpui_base::POPUP_PRIORITY)
211 .into_any(),
212 );
213 }
214 }
215
216 let mut element = this
217 .element
218 .take()
219 .expect("Element should exists.")
220 .children(menu_element)
221 .into_any_element();
222
223 let layout_id = element.request_layout(window, cx);
224
225 (
226 layout_id,
227 ContextMenuState {
228 element: Some(element),
229 ..Default::default()
230 },
231 )
232 },
233 )
234 }
235
236 fn prepaint(
237 &mut self,
238 _: Option<&gpui::GlobalElementId>,
239 _: Option<&InspectorElementId>,
240 bounds: gpui::Bounds<gpui::Pixels>,
241 request_layout: &mut Self::RequestLayoutState,
242 window: &mut Window,
243 cx: &mut App,
244 ) -> Self::PrepaintState {
245 if let Some(element) = &mut request_layout.element {
246 element.prepaint(window, cx);
247 }
248 window.insert_hitbox(bounds, HitboxBehavior::Normal)
249 }
250
251 fn paint(
252 &mut self,
253 id: Option<&gpui::GlobalElementId>,
254 _: Option<&InspectorElementId>,
255 _: gpui::Bounds<gpui::Pixels>,
256 request_layout: &mut Self::RequestLayoutState,
257 hitbox: &mut Self::PrepaintState,
258 window: &mut Window,
259 cx: &mut App,
260 ) {
261 if let Some(element) = &mut request_layout.element {
262 element.paint(window, cx);
263 }
264
265 let builder = self.menu.clone();
267
268 self.with_element_state(
269 id.unwrap(),
270 window,
271 cx,
272 |_view, state: &mut ContextMenuState, window, _| {
273 let shared_state = state.shared_state.clone();
274
275 let hitbox = hitbox.clone();
276 window.on_mouse_event(move |event: &MouseDownEvent, phase, window, cx| {
278 if phase.bubble()
279 && event.button == MouseButton::Right
280 && hitbox.is_hovered(window)
281 {
282 let previous_focus_handle = window.focused(cx).and_then(|focused| {
285 let shared_state = shared_state.borrow();
286 match shared_state.menu_view.as_ref() {
287 Some(menu) if menu.read(cx).focus_handle == focused => {
288 menu.read(cx).previous_focus_handle.clone()
289 }
290 _ => Some(focused),
291 }
292 });
293
294 {
295 let mut shared_state = shared_state.borrow_mut();
296 shared_state.menu_view = None;
299 shared_state._subscription = None;
300 shared_state.position = event.position;
301 shared_state.open = true;
302 }
303
304 window.defer(cx, {
306 let shared_state = shared_state.clone();
307 let builder = builder.clone();
308 move |window, cx| {
309 let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
310 let Some(build) = &builder else {
311 return menu;
312 };
313 build(menu, window, cx)
314 });
315 menu.update(cx, |menu, cx| {
316 menu.set_previous_focus(previous_focus_handle, cx);
317 });
318
319 let _subscription = window.subscribe(&menu, cx, {
321 let shared_state = shared_state.clone();
322 move |_, _: &DismissEvent, window, _cx| {
323 shared_state.borrow_mut().open = false;
324 window.refresh();
325 }
326 });
327
328 {
330 let mut state = shared_state.borrow_mut();
331 state.menu_view = Some(menu.clone());
332 state._subscription = Some(_subscription);
333 window.refresh();
334 }
335 }
336 });
337 }
338 });
339 },
340 );
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use crate::theme::Theme;
348 use gpui::{
349 Context, FocusHandle, IntoElement, Render, TestAppContext, VisualTestContext, actions,
350 point, px,
351 };
352 use std::cell::Cell;
353
354 actions!(context_menu_test, [RemoveTab]);
355
356 struct TestRoot {
360 content_focus: FocusHandle,
361 received: Rc<Cell<bool>>,
362 }
363
364 impl Render for TestRoot {
365 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
366 let received = self.received.clone();
367 div()
368 .size_full()
369 .child(
370 div()
371 .id("content")
372 .h(px(40.))
373 .track_focus(&self.content_focus),
374 )
375 .child(
376 div()
377 .id("action-bar")
378 .h(px(60.))
379 .on_action(move |_: &RemoveTab, _, _| received.set(true))
380 .child(
381 div()
382 .id("tab")
383 .size_full()
384 .context_menu(|menu, _, _| menu.menu("Close", Box::new(RemoveTab))),
385 ),
386 )
387 }
388 }
389
390 #[gpui::test]
391 fn action_bubbles_from_trigger_and_focus_restores_on_dismiss(cx: &mut TestAppContext) {
392 cx.update(|cx| {
393 cx.set_global(Theme::default());
394 super::super::popup_menu::init(cx);
395 });
396
397 let received = Rc::new(Cell::new(false));
398 let (root, cx) = cx.add_window_view({
399 let received = received.clone();
400 move |window, cx| {
401 let content_focus = cx.focus_handle();
402 content_focus.focus(window, cx);
403 TestRoot {
404 content_focus,
405 received,
406 }
407 }
408 });
409 let content_focus = root.read_with(cx, |root, _| root.content_focus.clone());
410 let cx: &mut VisualTestContext = cx;
411 cx.run_until_parked();
412 cx.update(|window, cx| {
413 _ = window.draw(cx);
414 });
415
416 cx.simulate_event(MouseDownEvent {
418 button: MouseButton::Right,
419 position: point(px(50.), px(70.)),
420 modifiers: Default::default(),
421 click_count: 1,
422 first_mouse: false,
423 });
424 cx.run_until_parked();
427 cx.update(|window, cx| {
428 _ = window.draw(cx);
429 });
430
431 cx.simulate_keystrokes("down enter");
434 cx.run_until_parked();
435
436 assert!(received.get());
439 cx.update(|window, cx| {
442 assert_eq!(window.focused(cx).as_ref(), Some(&content_focus));
443 });
444 }
445}