1use std::rc::Rc;
2
3use gpui::{
4 Anchor, AnyElement, App, Context, DismissEvent, Element, ElementId, Entity, FocusHandle,
5 Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement, LayoutId,
6 RenderOnce, SharedString, Styled, Window, prelude::FluentBuilder,
7};
8
9use crate::{Selectable, button::Button, menu::PopupMenu, popover::Popover};
10
11pub trait DropdownMenu: Styled + Selectable + InteractiveElement + IntoElement + 'static {
13 fn dropdown_menu(
15 self,
16 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
17 ) -> DropdownMenuPopover<Self> {
18 self.dropdown_menu_with_anchor(Anchor::TopLeft, f)
19 }
20
21 fn dropdown_menu_with_anchor(
23 mut self,
24 anchor: impl Into<Anchor>,
25 f: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
26 ) -> DropdownMenuPopover<Self> {
27 let id = self.interactivity().element_id.clone();
28
29 DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f)
30 }
31}
32
33impl DropdownMenu for Button {}
34
35#[derive(IntoElement)]
36pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
37 id: ElementId,
38 anchor: Anchor,
39 trigger: T,
40 builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
41 on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
42}
43
44impl<T> DropdownMenuPopover<T>
45where
46 T: Selectable + IntoElement + 'static,
47{
48 fn new(
49 id: ElementId,
50 anchor: impl Into<Anchor>,
51 trigger: T,
52 builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
53 ) -> Self {
54 Self {
55 id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
56 anchor: anchor.into(),
57 trigger,
58 builder: Rc::new(builder),
59 on_open_change: None,
60 }
61 }
62
63 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
65 self.anchor = anchor.into();
66 self
67 }
68
69 pub fn on_open_change(
73 mut self,
74 callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
75 ) -> Self {
76 self.on_open_change = Some(Rc::new(callback));
77 self
78 }
79}
80
81#[derive(Default)]
82struct DropdownMenuState {
83 menu: Option<Entity<PopupMenu>>,
84}
85
86impl<T> RenderOnce for DropdownMenuPopover<T>
87where
88 T: Selectable + IntoElement + 'static,
89{
90 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
91 TriggerFocus::new(self.id.clone(), move |trigger_focus, window, cx| {
92 self.render_popover(trigger_focus, window, cx)
93 .into_any_element()
94 })
95 }
96}
97
98impl<T> DropdownMenuPopover<T>
99where
100 T: Selectable + IntoElement + 'static,
101{
102 fn render_popover(
103 self,
104 trigger_focus: FocusHandle,
105 window: &mut Window,
106 cx: &mut App,
107 ) -> Popover {
108 let builder = self.builder.clone();
109 let menu_state =
110 window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
111
112 Popover::new(SharedString::from(format!("popover:{}", self.id)))
113 .appearance(false)
114 .overlay_closable(false)
115 .trigger(self.trigger)
116 .anchor(self.anchor)
117 .when_some(self.on_open_change, |this, callback| {
118 this.on_open_change(move |open, window, cx| callback(open, window, cx))
119 })
120 .content(move |_, window, cx| {
121 let menu = match menu_state.read(cx).menu.clone() {
128 Some(menu) => menu,
129 None => {
130 let builder = builder.clone();
131 let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
132 builder(menu, window, cx)
133 });
134 menu.update(cx, |menu, cx| {
135 menu.set_trigger_focus(Some(trigger_focus.clone()), cx)
136 });
137 menu_state.update(cx, |state, _| {
138 state.menu = Some(menu.clone());
139 });
140 menu.focus_handle(cx).focus(window, cx);
141
142 let popover_state = cx.entity().downgrade();
151 window
152 .subscribe(&menu, cx, {
153 let menu_state = menu_state.downgrade();
154 move |_, _: &DismissEvent, window, cx| {
155 if let Some(popover_state) = popover_state.upgrade() {
156 popover_state.update(cx, |state, cx| {
157 state.dismiss(window, cx);
158 });
159 }
160 _ = menu_state.update(cx, |state, _| {
161 state.menu = None;
162 });
163 }
164 })
165 .detach();
166
167 menu.clone()
168 }
169 };
170
171 menu.clone()
172 })
173 }
174}
175
176type TriggerFocusBuild = Box<dyn FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement>;
177
178struct TriggerFocus {
184 id: ElementId,
185 build: Option<TriggerFocusBuild>,
186}
187
188#[derive(Default)]
189struct TriggerFocusState {
190 focus_handle: Option<FocusHandle>,
191}
192
193struct TriggerFocusFrame {
194 focus_handle: FocusHandle,
195 child: AnyElement,
196}
197
198impl TriggerFocus {
199 fn new(
200 id: ElementId,
201 build: impl FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement + 'static,
202 ) -> Self {
203 Self {
204 id,
205 build: Some(Box::new(build)),
206 }
207 }
208}
209
210impl IntoElement for TriggerFocus {
211 type Element = Self;
212
213 fn into_element(self) -> Self::Element {
214 self
215 }
216}
217
218impl Element for TriggerFocus {
219 type RequestLayoutState = TriggerFocusFrame;
220 type PrepaintState = ();
221
222 fn id(&self) -> Option<ElementId> {
223 Some(self.id.clone())
224 }
225
226 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
227 None
228 }
229
230 fn request_layout(
231 &mut self,
232 id: Option<&GlobalElementId>,
233 _: Option<&InspectorElementId>,
234 window: &mut Window,
235 cx: &mut App,
236 ) -> (LayoutId, Self::RequestLayoutState) {
237 let focus_handle =
238 window.with_optional_element_state::<TriggerFocusState, _>(id, |state, _| {
239 let mut state = state.flatten().unwrap_or_default();
240 let focus_handle = state
241 .focus_handle
242 .get_or_insert_with(|| cx.focus_handle())
243 .clone();
244 (focus_handle, Some(state))
245 });
246 let build = self.build.take().expect("TriggerFocus is laid out once");
247 let mut child = build(focus_handle.clone(), window, cx);
248 let layout_id = child.request_layout(window, cx);
249
250 (
251 layout_id,
252 TriggerFocusFrame {
253 focus_handle,
254 child,
255 },
256 )
257 }
258
259 fn prepaint(
260 &mut self,
261 _: Option<&GlobalElementId>,
262 _: Option<&InspectorElementId>,
263 _: gpui::Bounds<gpui::Pixels>,
264 frame: &mut Self::RequestLayoutState,
265 window: &mut Window,
266 cx: &mut App,
267 ) {
268 window.set_focus_handle(&frame.focus_handle, cx);
269 frame.child.prepaint(window, cx);
270 }
271
272 fn paint(
273 &mut self,
274 _: Option<&GlobalElementId>,
275 _: Option<&InspectorElementId>,
276 _: gpui::Bounds<gpui::Pixels>,
277 frame: &mut Self::RequestLayoutState,
278 _: &mut Self::PrepaintState,
279 window: &mut Window,
280 cx: &mut App,
281 ) {
282 frame.child.paint(window, cx);
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use gpui::{
290 KeyBinding, MouseButton, ParentElement as _, Render, TestAppContext, WeakEntity, actions,
291 div, point, px,
292 };
293 use std::cell::{Cell, RefCell};
294
295 actions!(dropdown_menu_test, [CopyText]);
296
297 const CONTEXT: &str = "dropdown_menu_test";
298
299 struct TestRoot {
303 frames: Rc<Cell<usize>>,
304 }
305
306 impl Render for TestRoot {
307 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
308 self.frames.set(self.frames.get() + 1);
309 div()
310 .size_full()
311 .child(
312 div()
313 .key_context(CONTEXT)
314 .on_action(|_: &CopyText, _, _| {})
315 .child(
316 Button::new("trigger")
317 .label("Edit")
318 .w(px(100.))
319 .h(px(30.))
320 .dropdown_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
321 ),
322 )
323 .child(div().child("Status"))
324 }
325 }
326
327 struct MenuProbeRoot {
330 menu: Rc<RefCell<Option<WeakEntity<PopupMenu>>>>,
331 }
332
333 impl Render for MenuProbeRoot {
334 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
335 let slot = self.menu.clone();
336 div().size_full().child(
337 Button::new("trigger")
338 .label("Edit")
339 .w(px(100.))
340 .h(px(30.))
341 .dropdown_menu(move |menu, _, cx| {
342 *slot.borrow_mut() = Some(cx.weak_entity());
343 menu.menu("Copy", Box::new(CopyText))
344 }),
345 )
346 }
347 }
348
349 #[gpui::test]
360 fn open_without_dismiss_releases_the_menu(cx: &mut TestAppContext) {
361 cx.update(|cx| crate::init(cx));
362 let menu = Rc::new(RefCell::new(None::<WeakEntity<PopupMenu>>));
363
364 {
365 let (_, cx) = cx.add_window_view(|_, _| MenuProbeRoot { menu: menu.clone() });
366 cx.update(|window, cx| window.draw(cx).clear(cx));
367
368 cx.simulate_mouse_down(
370 point(px(10.), px(10.)),
371 MouseButton::Left,
372 Default::default(),
373 );
374 cx.run_until_parked();
375 assert!(
376 menu.borrow()
377 .as_ref()
378 .and_then(|menu| menu.upgrade())
379 .is_some(),
380 "the menu must be open before the window closes"
381 );
382
383 cx.update(|window, _| window.remove_window());
385 cx.run_until_parked();
386 }
387
388 assert!(
389 menu.borrow()
390 .as_ref()
391 .and_then(|menu| menu.upgrade())
392 .is_none(),
393 "the PopupMenu must be released with the window"
394 );
395 cx.update(|cx| {
396 assert!(
397 !gpui_base::GlobalState::is_in_deferred_context(cx),
398 "the popover's deferred registration must be released with the window"
399 )
400 });
401 }
402
403 #[gpui::test]
404 fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
405 cx.update(|cx| {
406 crate::init(cx);
407 cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
408 });
409 let frames = Rc::new(Cell::new(0));
410 let (_, cx) = cx.add_window_view({
411 let frames = frames.clone();
412 move |_, _| TestRoot { frames }
413 });
414 cx.update(|window, cx| window.draw(cx).clear(cx));
416 let frames_before_open = frames.get();
417
418 cx.simulate_mouse_down(
419 point(px(10.), px(10.)),
420 MouseButton::Left,
421 Default::default(),
422 );
423
424 assert_eq!(
425 frames.get(),
426 frames_before_open + 1,
427 "the press must be followed by exactly one frame for this to test the first one"
428 );
429 assert!(
430 cx.debug_bounds("kbd:ctrl-c").is_some(),
431 "the shortcut hint must be painted on the same frame as its item"
432 );
433 }
434}