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, StyleRefinement, 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 style = self.style().clone();
28 let id = self.interactivity().element_id.clone();
29
30 DropdownMenuPopover::new(id.unwrap_or(0.into()), anchor, self, f).trigger_style(style)
31 }
32}
33
34impl DropdownMenu for Button {}
35
36#[derive(IntoElement)]
37pub struct DropdownMenuPopover<T: Selectable + IntoElement + 'static> {
38 id: ElementId,
39 style: StyleRefinement,
40 anchor: Anchor,
41 trigger: T,
42 builder: Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>,
43 on_open_change: Option<Rc<dyn Fn(&bool, &mut Window, &mut App)>>,
44}
45
46impl<T> DropdownMenuPopover<T>
47where
48 T: Selectable + IntoElement + 'static,
49{
50 fn new(
51 id: ElementId,
52 anchor: impl Into<Anchor>,
53 trigger: T,
54 builder: impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static,
55 ) -> Self {
56 Self {
57 id: SharedString::from(format!("dropdown-menu:{:?}", id)).into(),
58 style: StyleRefinement::default(),
59 anchor: anchor.into(),
60 trigger,
61 builder: Rc::new(builder),
62 on_open_change: None,
63 }
64 }
65
66 pub fn anchor(mut self, anchor: impl Into<Anchor>) -> Self {
68 self.anchor = anchor.into();
69 self
70 }
71
72 fn trigger_style(mut self, style: StyleRefinement) -> Self {
74 self.style = style;
75 self
76 }
77
78 pub fn on_open_change(
82 mut self,
83 callback: impl Fn(&bool, &mut Window, &mut App) + 'static,
84 ) -> Self {
85 self.on_open_change = Some(Rc::new(callback));
86 self
87 }
88}
89
90#[derive(Default)]
91struct DropdownMenuState {
92 menu: Option<Entity<PopupMenu>>,
93}
94
95impl<T> RenderOnce for DropdownMenuPopover<T>
96where
97 T: Selectable + IntoElement + 'static,
98{
99 fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
100 TriggerFocus::new(self.id.clone(), move |trigger_focus, window, cx| {
101 self.render_popover(trigger_focus, window, cx)
102 .into_any_element()
103 })
104 }
105}
106
107impl<T> DropdownMenuPopover<T>
108where
109 T: Selectable + IntoElement + 'static,
110{
111 fn render_popover(
112 self,
113 trigger_focus: FocusHandle,
114 window: &mut Window,
115 cx: &mut App,
116 ) -> Popover {
117 let builder = self.builder.clone();
118 let menu_state =
119 window.use_keyed_state(self.id.clone(), cx, |_, _| DropdownMenuState::default());
120
121 Popover::new(SharedString::from(format!("popover:{}", self.id)))
122 .appearance(false)
123 .overlay_closable(false)
124 .trigger(self.trigger)
125 .trigger_style(self.style)
126 .anchor(self.anchor)
127 .when_some(self.on_open_change, |this, callback| {
128 this.on_open_change(move |open, window, cx| callback(open, window, cx))
129 })
130 .content(move |_, window, cx| {
131 let menu = match menu_state.read(cx).menu.clone() {
138 Some(menu) => menu,
139 None => {
140 let builder = builder.clone();
141 let menu = PopupMenu::build(window, cx, move |menu, window, cx| {
142 builder(menu, window, cx)
143 });
144 menu.update(cx, |menu, cx| {
145 menu.set_trigger_focus(Some(trigger_focus.clone()), cx)
146 });
147 menu_state.update(cx, |state, _| {
148 state.menu = Some(menu.clone());
149 });
150 menu.focus_handle(cx).focus(window, cx);
151
152 let popover_state = cx.entity();
154 window
155 .subscribe(&menu, cx, {
156 let menu_state = menu_state.clone();
157 move |_, _: &DismissEvent, window, cx| {
158 popover_state.update(cx, |state, cx| {
159 state.dismiss(window, cx);
160 });
161 menu_state.update(cx, |state, _| {
162 state.menu = None;
163 });
164 }
165 })
166 .detach();
167
168 menu.clone()
169 }
170 };
171
172 menu.clone()
173 })
174 }
175}
176
177type TriggerFocusBuild = Box<dyn FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement>;
178
179struct TriggerFocus {
185 id: ElementId,
186 build: Option<TriggerFocusBuild>,
187}
188
189#[derive(Default)]
190struct TriggerFocusState {
191 focus_handle: Option<FocusHandle>,
192}
193
194struct TriggerFocusFrame {
195 focus_handle: FocusHandle,
196 child: AnyElement,
197}
198
199impl TriggerFocus {
200 fn new(
201 id: ElementId,
202 build: impl FnOnce(FocusHandle, &mut Window, &mut App) -> AnyElement + 'static,
203 ) -> Self {
204 Self {
205 id,
206 build: Some(Box::new(build)),
207 }
208 }
209}
210
211impl IntoElement for TriggerFocus {
212 type Element = Self;
213
214 fn into_element(self) -> Self::Element {
215 self
216 }
217}
218
219impl Element for TriggerFocus {
220 type RequestLayoutState = TriggerFocusFrame;
221 type PrepaintState = ();
222
223 fn id(&self) -> Option<ElementId> {
224 Some(self.id.clone())
225 }
226
227 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
228 None
229 }
230
231 fn request_layout(
232 &mut self,
233 id: Option<&GlobalElementId>,
234 _: Option<&InspectorElementId>,
235 window: &mut Window,
236 cx: &mut App,
237 ) -> (LayoutId, Self::RequestLayoutState) {
238 let focus_handle =
239 window.with_optional_element_state::<TriggerFocusState, _>(id, |state, _| {
240 let mut state = state.flatten().unwrap_or_default();
241 let focus_handle = state
242 .focus_handle
243 .get_or_insert_with(|| cx.focus_handle())
244 .clone();
245 (focus_handle, Some(state))
246 });
247 let build = self.build.take().expect("TriggerFocus is laid out once");
248 let mut child = build(focus_handle.clone(), window, cx);
249 let layout_id = child.request_layout(window, cx);
250
251 (
252 layout_id,
253 TriggerFocusFrame {
254 focus_handle,
255 child,
256 },
257 )
258 }
259
260 fn prepaint(
261 &mut self,
262 _: Option<&GlobalElementId>,
263 _: Option<&InspectorElementId>,
264 _: gpui::Bounds<gpui::Pixels>,
265 frame: &mut Self::RequestLayoutState,
266 window: &mut Window,
267 cx: &mut App,
268 ) {
269 window.set_focus_handle(&frame.focus_handle, cx);
270 frame.child.prepaint(window, cx);
271 }
272
273 fn paint(
274 &mut self,
275 _: Option<&GlobalElementId>,
276 _: Option<&InspectorElementId>,
277 _: gpui::Bounds<gpui::Pixels>,
278 frame: &mut Self::RequestLayoutState,
279 _: &mut Self::PrepaintState,
280 window: &mut Window,
281 cx: &mut App,
282 ) {
283 frame.child.paint(window, cx);
284 }
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290 use gpui::{
291 KeyBinding, MouseButton, ParentElement as _, Render, TestAppContext, actions, div, point,
292 px,
293 };
294 use std::cell::Cell;
295
296 actions!(dropdown_menu_test, [CopyText]);
297
298 const CONTEXT: &str = "dropdown_menu_test";
299
300 struct TestRoot {
304 frames: Rc<Cell<usize>>,
305 }
306
307 impl Render for TestRoot {
308 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
309 self.frames.set(self.frames.get() + 1);
310 div()
311 .size_full()
312 .child(
313 div()
314 .key_context(CONTEXT)
315 .on_action(|_: &CopyText, _, _| {})
316 .child(
317 Button::new("trigger")
318 .label("Edit")
319 .w(px(100.))
320 .h(px(30.))
321 .dropdown_menu(|menu, _, _| menu.menu("Copy", Box::new(CopyText))),
322 ),
323 )
324 .child(div().child("Status"))
325 }
326 }
327
328 #[gpui::test]
329 fn shortcut_hint_is_painted_on_the_frame_the_menu_opens(cx: &mut TestAppContext) {
330 cx.update(|cx| {
331 crate::init(cx);
332 cx.bind_keys([KeyBinding::new("ctrl-c", CopyText, Some(CONTEXT))]);
333 });
334 let frames = Rc::new(Cell::new(0));
335 let (_, cx) = cx.add_window_view({
336 let frames = frames.clone();
337 move |_, _| TestRoot { frames }
338 });
339 cx.update(|window, cx| window.draw(cx).clear(cx));
341 let frames_before_open = frames.get();
342
343 cx.simulate_mouse_down(
344 point(px(10.), px(10.)),
345 MouseButton::Left,
346 Default::default(),
347 );
348
349 assert_eq!(
350 frames.get(),
351 frames_before_open + 1,
352 "the press must be followed by exactly one frame for this to test the first one"
353 );
354 assert!(
355 cx.debug_bounds("kbd:ctrl-c").is_some(),
356 "the shortcut hint must be painted on the same frame as its item"
357 );
358 }
359}