1#![allow(missing_docs)]
2
3use std::{cell::RefCell, rc::Rc};
4
5use gpui::{
6 anchored, deferred, div, point, prelude::FluentBuilder, px, size, AnyElement, AnyView, App,
7 Bounds, Corner, DismissEvent, DispatchPhase, Element, ElementId, Entity, Focusable as _,
8 GlobalElementId, HitboxId, InteractiveElement, IntoElement, LayoutId, Length, ManagedView,
9 MouseDownEvent, ParentElement, Pixels, Point, Style, Window,
10};
11
12use crate::prelude::*;
13
14pub trait PopoverTrigger: IntoElement + Clickable + Toggleable + 'static {}
15
16impl<T: IntoElement + Clickable + Toggleable + 'static> PopoverTrigger for T {}
17
18impl<T: Clickable> Clickable for gpui::AnimationElement<T>
19where
20 T: Clickable + 'static,
21{
22 fn on_click(
23 self,
24 handler: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
25 ) -> Self {
26 self.map_element(|e| e.on_click(handler))
27 }
28
29 fn cursor_style(self, cursor_style: gpui::CursorStyle) -> Self {
30 self.map_element(|e| e.cursor_style(cursor_style))
31 }
32}
33
34impl<T: Toggleable> Toggleable for gpui::AnimationElement<T>
35where
36 T: Toggleable + 'static,
37{
38 fn toggle_state(self, selected: bool) -> Self {
39 self.map_element(|e| e.toggle_state(selected))
40 }
41}
42
43pub struct PopoverMenuHandle<M>(Rc<RefCell<Option<PopoverMenuHandleState<M>>>>);
44
45impl<M> Clone for PopoverMenuHandle<M> {
46 fn clone(&self) -> Self {
47 Self(self.0.clone())
48 }
49}
50
51impl<M> Default for PopoverMenuHandle<M> {
52 fn default() -> Self {
53 Self(Rc::default())
54 }
55}
56
57struct PopoverMenuHandleState<M> {
58 menu_builder: Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
59 menu: Rc<RefCell<Option<Entity<M>>>>,
60 on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
61}
62
63impl<M: ManagedView> PopoverMenuHandle<M> {
64 pub fn show(&self, window: &mut Window, cx: &mut App) {
65 if let Some(state) = self.0.borrow().as_ref() {
66 show_menu(
67 &state.menu_builder,
68 &state.menu,
69 state.on_open.clone(),
70 window,
71 cx,
72 );
73 }
74 }
75
76 pub fn hide(&self, cx: &mut App) {
77 if let Some(state) = self.0.borrow().as_ref() {
78 if let Some(menu) = state.menu.borrow().as_ref() {
79 menu.update(cx, |_, cx| cx.emit(DismissEvent));
80 }
81 }
82 }
83
84 pub fn toggle(&self, window: &mut Window, cx: &mut App) {
85 if let Some(state) = self.0.borrow().as_ref() {
86 if state.menu.borrow().is_some() {
87 self.hide(cx);
88 } else {
89 self.show(window, cx);
90 }
91 }
92 }
93
94 pub fn is_deployed(&self) -> bool {
95 self.0
96 .borrow()
97 .as_ref()
98 .map_or(false, |state| state.menu.borrow().as_ref().is_some())
99 }
100
101 pub fn is_focused(&self, window: &Window, cx: &App) -> bool {
102 self.0.borrow().as_ref().map_or(false, |state| {
103 state
104 .menu
105 .borrow()
106 .as_ref()
107 .map_or(false, |model| model.focus_handle(cx).is_focused(window))
108 })
109 }
110}
111
112pub struct PopoverMenu<M: ManagedView> {
113 id: ElementId,
114 child_builder: Option<
115 Box<
116 dyn FnOnce(
117 Rc<RefCell<Option<Entity<M>>>>,
118 Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
119 ) -> AnyElement
120 + 'static,
121 >,
122 >,
123 menu_builder: Option<Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static>>,
124 anchor: Corner,
125 attach: Option<Corner>,
126 offset: Option<Point<Pixels>>,
127 trigger_handle: Option<PopoverMenuHandle<M>>,
128 on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
129 full_width: bool,
130}
131
132impl<M: ManagedView> PopoverMenu<M> {
133 /// Returns a new [`PopoverMenu`].
134 pub fn new(id: impl Into<ElementId>) -> Self {
135 Self {
136 id: id.into(),
137 child_builder: None,
138 menu_builder: None,
139 anchor: Corner::TopLeft,
140 attach: None,
141 offset: None,
142 trigger_handle: None,
143 on_open: None,
144 full_width: false,
145 }
146 }
147
148 pub fn full_width(mut self, full_width: bool) -> Self {
149 self.full_width = full_width;
150 self
151 }
152
153 pub fn menu(
154 mut self,
155 f: impl Fn(&mut Window, &mut App) -> Option<Entity<M>> + 'static,
156 ) -> Self {
157 self.menu_builder = Some(Rc::new(f));
158 self
159 }
160
161 pub fn with_handle(mut self, handle: PopoverMenuHandle<M>) -> Self {
162 self.trigger_handle = Some(handle);
163 self
164 }
165
166 pub fn trigger<T: PopoverTrigger>(mut self, t: T) -> Self {
167 let on_open = self.on_open.clone();
168 self.child_builder = Some(Box::new(move |menu, builder| {
169 let open = menu.borrow().is_some();
170 t.toggle_state(open)
171 .when_some(builder, |el, builder| {
172 el.on_click(move |_event, window, cx| {
173 show_menu(&builder, &menu, on_open.clone(), window, cx)
174 })
175 })
176 .into_any_element()
177 }));
178 self
179 }
180
181 /// This method prevents the trigger button tooltip from being seen when the menu is open.
182 pub fn trigger_with_tooltip<T: PopoverTrigger + ButtonCommon>(
183 mut self,
184 t: T,
185 tooltip_builder: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
186 ) -> Self {
187 let on_open = self.on_open.clone();
188 self.child_builder = Some(Box::new(move |menu, builder| {
189 let open = menu.borrow().is_some();
190 t.toggle_state(open)
191 .when_some(builder, |el, builder| {
192 el.on_click(move |_, window, cx| {
193 show_menu(&builder, &menu, on_open.clone(), window, cx)
194 })
195 .when(!open, |t| {
196 t.tooltip(move |window, cx| tooltip_builder(window, cx))
197 })
198 })
199 .into_any_element()
200 }));
201 self
202 }
203
204 /// Defines which corner of the menu to anchor to the attachment point.
205 /// By default, it uses the cursor position. Also see the `attach` method.
206 pub fn anchor(mut self, anchor: Corner) -> Self {
207 self.anchor = anchor;
208 self
209 }
210
211 /// Defines which corner of the handle to attach the menu's anchor to.
212 pub fn attach(mut self, attach: Corner) -> Self {
213 self.attach = Some(attach);
214 self
215 }
216
217 /// Offsets the position of the content by that many pixels.
218 pub fn offset(mut self, offset: Point<Pixels>) -> Self {
219 self.offset = Some(offset);
220 self
221 }
222
223 /// Attaches something upon opening the menu.
224 pub fn on_open(mut self, on_open: Rc<dyn Fn(&mut Window, &mut App)>) -> Self {
225 self.on_open = Some(on_open);
226 self
227 }
228
229 fn resolved_attach(&self) -> Corner {
230 self.attach.unwrap_or(match self.anchor {
231 Corner::TopLeft => Corner::BottomLeft,
232 Corner::TopRight => Corner::BottomRight,
233 Corner::BottomLeft => Corner::TopLeft,
234 Corner::BottomRight => Corner::TopRight,
235 })
236 }
237
238 fn resolved_offset(&self, window: &mut Window) -> Point<Pixels> {
239 self.offset.unwrap_or_else(|| {
240 // Default offset = 4px padding + 1px border
241 let offset = rems_from_px(5.) * window.rem_size();
242 match self.anchor {
243 Corner::TopRight | Corner::BottomRight => point(offset, px(0.)),
244 Corner::TopLeft | Corner::BottomLeft => point(-offset, px(0.)),
245 }
246 })
247 }
248}
249
250fn show_menu<M: ManagedView>(
251 builder: &Rc<dyn Fn(&mut Window, &mut App) -> Option<Entity<M>>>,
252 menu: &Rc<RefCell<Option<Entity<M>>>>,
253 on_open: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
254 window: &mut Window,
255 cx: &mut App,
256) {
257 let Some(new_menu) = (builder)(window, cx) else {
258 return;
259 };
260 let menu2 = menu.clone();
261 let previous_focus_handle = window.focused(cx);
262
263 window
264 .subscribe(&new_menu, cx, move |modal, _: &DismissEvent, window, cx| {
265 if modal.focus_handle(cx).contains_focused(window, cx) {
266 if let Some(previous_focus_handle) = previous_focus_handle.as_ref() {
267 window.focus(previous_focus_handle);
268 }
269 }
270 *menu2.borrow_mut() = None;
271 window.refresh();
272 })
273 .detach();
274 window.focus(&new_menu.focus_handle(cx));
275 *menu.borrow_mut() = Some(new_menu);
276 window.refresh();
277
278 if let Some(on_open) = on_open {
279 on_open(window, cx);
280 }
281}
282
283pub struct PopoverMenuElementState<M> {
284 menu: Rc<RefCell<Option<Entity<M>>>>,
285 child_bounds: Option<Bounds<Pixels>>,
286}
287
288impl<M> Clone for PopoverMenuElementState<M> {
289 fn clone(&self) -> Self {
290 Self {
291 menu: Rc::clone(&self.menu),
292 child_bounds: self.child_bounds,
293 }
294 }
295}
296
297impl<M> Default for PopoverMenuElementState<M> {
298 fn default() -> Self {
299 Self {
300 menu: Rc::default(),
301 child_bounds: None,
302 }
303 }
304}
305
306pub struct PopoverMenuFrameState<M: ManagedView> {
307 child_layout_id: Option<LayoutId>,
308 child_element: Option<AnyElement>,
309 menu_element: Option<AnyElement>,
310 menu_handle: Rc<RefCell<Option<Entity<M>>>>,
311}
312
313impl<M: ManagedView> Element for PopoverMenu<M> {
314 type RequestLayoutState = PopoverMenuFrameState<M>;
315 type PrepaintState = Option<HitboxId>;
316
317 fn id(&self) -> Option<ElementId> {
318 Some(self.id.clone())
319 }
320
321 fn request_layout(
322 &mut self,
323 global_id: Option<&GlobalElementId>,
324 window: &mut Window,
325 cx: &mut App,
326 ) -> (gpui::LayoutId, Self::RequestLayoutState) {
327 window.with_element_state(
328 global_id.unwrap(),
329 |element_state: Option<PopoverMenuElementState<M>>, window| {
330 let element_state = element_state.unwrap_or_default();
331 let mut menu_layout_id = None;
332
333 let menu_element = element_state.menu.borrow_mut().as_mut().map(|menu| {
334 let offset = self.resolved_offset(window);
335 let mut anchored = anchored()
336 .snap_to_window_with_margin(px(8.))
337 .anchor(self.anchor)
338 .offset(offset);
339 if let Some(child_bounds) = element_state.child_bounds {
340 anchored =
341 anchored.position(child_bounds.corner(self.resolved_attach()) + offset);
342 }
343 let mut element = deferred(anchored.child(div().occlude().child(menu.clone())))
344 .with_priority(1)
345 .into_any();
346
347 menu_layout_id = Some(element.request_layout(window, cx));
348 element
349 });
350
351 let mut child_element = self.child_builder.take().map(|child_builder| {
352 (child_builder)(element_state.menu.clone(), self.menu_builder.clone())
353 });
354
355 if let Some(trigger_handle) = self.trigger_handle.take() {
356 if let Some(menu_builder) = self.menu_builder.clone() {
357 *trigger_handle.0.borrow_mut() = Some(PopoverMenuHandleState {
358 menu_builder,
359 menu: element_state.menu.clone(),
360 on_open: self.on_open.clone(),
361 });
362 }
363 }
364
365 let child_layout_id = child_element
366 .as_mut()
367 .map(|child_element| child_element.request_layout(window, cx));
368
369 let mut style = Style::default();
370 if self.full_width {
371 style.size = size(relative(1.).into(), Length::Auto);
372 }
373
374 let layout_id = window.request_layout(
375 style,
376 menu_layout_id.into_iter().chain(child_layout_id),
377 cx,
378 );
379
380 (
381 (
382 layout_id,
383 PopoverMenuFrameState {
384 child_element,
385 child_layout_id,
386 menu_element,
387 menu_handle: element_state.menu.clone(),
388 },
389 ),
390 element_state,
391 )
392 },
393 )
394 }
395
396 fn prepaint(
397 &mut self,
398 global_id: Option<&GlobalElementId>,
399 _bounds: Bounds<Pixels>,
400 request_layout: &mut Self::RequestLayoutState,
401 window: &mut Window,
402 cx: &mut App,
403 ) -> Option<HitboxId> {
404 if let Some(child) = request_layout.child_element.as_mut() {
405 child.prepaint(window, cx);
406 }
407
408 if let Some(menu) = request_layout.menu_element.as_mut() {
409 menu.prepaint(window, cx);
410 }
411
412 request_layout.child_layout_id.map(|layout_id| {
413 let bounds = window.layout_bounds(layout_id);
414 window.with_element_state(global_id.unwrap(), |element_state, _cx| {
415 let mut element_state: PopoverMenuElementState<M> = element_state.unwrap();
416 element_state.child_bounds = Some(bounds);
417 ((), element_state)
418 });
419
420 window.insert_hitbox(bounds, false).id
421 })
422 }
423
424 fn paint(
425 &mut self,
426 _id: Option<&GlobalElementId>,
427 _: Bounds<gpui::Pixels>,
428 request_layout: &mut Self::RequestLayoutState,
429 child_hitbox: &mut Option<HitboxId>,
430 window: &mut Window,
431 cx: &mut App,
432 ) {
433 if let Some(mut child) = request_layout.child_element.take() {
434 child.paint(window, cx);
435 }
436
437 if let Some(mut menu) = request_layout.menu_element.take() {
438 menu.paint(window, cx);
439
440 if let Some(child_hitbox) = *child_hitbox {
441 let menu_handle = request_layout.menu_handle.clone();
442 // Mouse-downing outside the menu dismisses it, so we don't
443 // want a click on the toggle to re-open it.
444 window.on_mouse_event(move |_: &MouseDownEvent, phase, window, cx| {
445 if phase == DispatchPhase::Bubble && child_hitbox.is_hovered(window) {
446 if let Some(menu) = menu_handle.borrow().as_ref() {
447 menu.update(cx, |_, cx| {
448 cx.emit(DismissEvent);
449 });
450 }
451 cx.stop_propagation();
452 }
453 })
454 }
455 }
456 }
457}
458
459impl<M: ManagedView> IntoElement for PopoverMenu<M> {
460 type Element = Self;
461
462 fn into_element(self) -> Self::Element {
463 self
464 }
465}