1use std::rc::Rc;
2
3use gpui::{
4 anchored, deferred, div, point, prelude::*, px, relative, AnyElement, App, Corner, Div, Entity,
5 FocusHandle, FontWeight, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels,
6 RenderOnce, SharedString, StyleRefinement, Styled, Window,
7};
8
9use crate::chrome::box_shadow;
10use crate::compat::{AccessibilityExt, Role, StyleCompatExt};
11use crate::motion::{Motion, StyledSlot};
12use crate::theme::{paint, ActiveTheme, Theme, ThemeKind};
13
14type PopoverOpenChangeHandler = Rc<dyn Fn(bool, &mut Window, &mut App) + 'static>;
15
16struct PopoverState {
17 focus_handle: FocusHandle,
18 open: bool,
19}
20
21fn set_open(
22 state: &Entity<PopoverState>,
23 open: bool,
24 on_open_change: Option<&PopoverOpenChangeHandler>,
25 window: &mut Window,
26 cx: &mut App,
27) {
28 if state.read(cx).open == open {
29 return;
30 }
31
32 state.update(cx, |state, cx| {
33 state.open = open;
34 cx.notify();
35 });
36
37 if let Some(on_open_change) = on_open_change {
38 on_open_change(open, window, cx);
39 }
40 window.refresh();
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
44pub enum PopoverPlacement {
45 Top,
46 #[default]
47 Bottom,
48 Start,
49 End,
50}
51
52impl PopoverPlacement {
53 fn anchor(self) -> Corner {
54 match self {
55 Self::Top => Corner::BottomLeft,
56 Self::Bottom => Corner::TopLeft,
57 Self::Start => Corner::TopRight,
58 Self::End => Corner::TopLeft,
59 }
60 }
61
62 fn offset(self, gap: Pixels) -> gpui::Point<Pixels> {
63 match self {
64 Self::Top => point(px(0.), -gap),
65 Self::Bottom => point(px(0.), gap),
66 Self::Start => point(-gap, px(0.)),
67 Self::End => point(gap, px(0.)),
68 }
69 }
70
71 fn marker(self) -> Div {
72 let marker = div().absolute().size(px(0.));
73
74 match self {
75 Self::Top => marker.left(px(0.)).top(px(0.)),
76 Self::Bottom => marker.left(px(0.)).top(relative(1.)),
77 Self::Start => marker.left(px(0.)).top(relative(0.5)),
78 Self::End => marker.left(relative(1.)).top(relative(0.5)),
79 }
80 }
81}
82
83#[derive(IntoElement)]
85pub struct Popover {
86 id: SharedString,
87 controlled_open: Option<bool>,
88 default_open: bool,
89 placement: PopoverPlacement,
90 gap: Pixels,
91 trigger_label: SharedString,
92 trigger: Option<AnyElement>,
93 on_open_change: Option<PopoverOpenChangeHandler>,
94 style: StyleRefinement,
95 children: Vec<AnyElement>,
96}
97
98impl Popover {
99 pub fn new(id: impl Into<SharedString>) -> Self {
100 Self {
101 id: id.into(),
102 controlled_open: None,
103 default_open: false,
104 placement: PopoverPlacement::Bottom,
105 gap: px(6.),
106 trigger_label: SharedString::from("Toggle popover"),
107 trigger: None,
108 on_open_change: None,
109 style: StyleRefinement::default(),
110 children: Vec::new(),
111 }
112 }
113
114 pub fn open(mut self, open: bool) -> Self {
115 self.controlled_open = Some(open);
116 self
117 }
118
119 pub fn default_open(mut self, open: bool) -> Self {
120 self.default_open = open;
121 self
122 }
123
124 pub fn placement(mut self, placement: PopoverPlacement) -> Self {
125 self.placement = placement;
126 self
127 }
128
129 pub fn gap(mut self, gap: impl Into<Pixels>) -> Self {
130 self.gap = gap.into();
131 self
132 }
133
134 pub fn trigger_label(mut self, label: impl Into<SharedString>) -> Self {
135 self.trigger_label = label.into();
136 self
137 }
138
139 pub fn trigger(mut self, trigger: impl IntoElement) -> Self {
140 self.trigger = Some(trigger.into_any_element());
141 self
142 }
143
144 pub fn on_open_change(
145 mut self,
146 listener: impl Fn(bool, &mut Window, &mut App) + 'static,
147 ) -> Self {
148 self.on_open_change = Some(Rc::new(listener));
149 self
150 }
151}
152
153impl Styled for Popover {
154 fn style(&mut self) -> &mut StyleRefinement {
155 &mut self.style
156 }
157}
158
159impl ParentElement for Popover {
160 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
161 self.children.extend(elements);
162 }
163}
164
165impl RenderOnce for Popover {
166 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
167 let initial_open = self.controlled_open.unwrap_or(self.default_open);
168 let state = window.use_keyed_state(self.id.clone(), cx, move |_, cx| PopoverState {
169 focus_handle: cx.focus_handle().tab_stop(true),
170 open: initial_open,
171 });
172
173 if let Some(controlled_open) = self.controlled_open {
174 if state.read(cx).open != controlled_open {
175 state.update(cx, |state, _| state.open = controlled_open);
176 }
177 }
178
179 let open = state.read(cx).open;
180 let focus_handle = state.read(cx).focus_handle.clone();
181 let focused = focus_handle.is_focused(window);
182 let trigger_selector = format!("{}-trigger", self.id);
183 let content_selector = format!("{}-content", self.id);
184 let surface_id = format!("{}-surface", self.id);
185 let trigger_id = SharedString::from(trigger_selector.clone());
186 let content_id = SharedString::from(content_selector.clone());
187 let click_state = state.clone();
188 let click_change = self.on_open_change.clone();
189 let click_focus = focus_handle.clone();
190 let click_next_open = !open;
191 let keyboard_state = state.clone();
192 let keyboard_change = self.on_open_change.clone();
193 let keyboard_focus = focus_handle.clone();
194 let dismiss_state = state.clone();
195 let dismiss_change = self.on_open_change.clone();
196 let ring = if cx.theme().is_dark() {
197 paint(0xFFFFFF24)
198 } else {
199 paint(0x18181B24)
200 };
201
202 let trigger = div()
203 .id(trigger_id)
204 .debug_selector(move || trigger_selector.clone())
205 .role(Role::Button)
206 .aria_label(self.trigger_label)
207 .aria_expanded(open)
208 .track_focus(&focus_handle)
209 .tab_stop(true)
210 .relative()
211 .flex()
212 .items_center()
213 .rounded(px(6.))
214 .cursor_pointer()
215 .when(focused, |el| {
216 el.shadow(vec![box_shadow(0., 0., ring, 0., 3.)])
217 })
218 .on_key_down(move |event: &KeyDownEvent, window, cx| {
219 if event.keystroke.modifiers.modified() {
220 return;
221 }
222
223 match event.keystroke.key.as_str() {
224 "enter" | "space" => {
225 let next_open = !keyboard_state.read(cx).open;
226 set_open(
227 &keyboard_state,
228 next_open,
229 keyboard_change.as_ref(),
230 window,
231 cx,
232 );
233 cx.stop_propagation();
234 }
235 "escape" if keyboard_state.read(cx).open => {
236 set_open(&keyboard_state, false, keyboard_change.as_ref(), window, cx);
237 keyboard_focus.focus(window);
238 cx.stop_propagation();
239 }
240 _ => {}
241 }
242 })
243 .on_mouse_down(MouseButton::Left, move |_, window, cx| {
244 click_focus.focus(window);
245 set_open(
246 &click_state,
247 click_next_open,
248 click_change.as_ref(),
249 window,
250 cx,
251 );
252 })
253 .when_some(self.trigger, |el, trigger| el.child(trigger));
254
255 let placement = self.placement;
256 let gap = self.gap;
257 let surface = Motion::new().id(surface_id).surface_in().child(
258 div()
259 .id(content_id)
260 .debug_selector(move || content_selector.clone())
261 .role(Role::Dialog)
262 .occlude()
263 .on_mouse_down_out(move |_, window, cx| {
264 set_open(&dismiss_state, false, dismiss_change.as_ref(), window, cx);
265 })
266 .children(self.children),
267 );
268 let popup = match placement {
269 PopoverPlacement::Top | PopoverPlacement::Bottom => placement
270 .marker()
271 .child(
272 deferred(
273 anchored()
274 .anchor(placement.anchor())
275 .offset(placement.offset(gap))
276 .snap_to_window_with_margin(px(8.))
277 .child(surface),
278 )
279 .with_priority(2),
280 )
281 .into_any_element(),
282 PopoverPlacement::Start => div()
283 .absolute()
284 .top(px(0.))
285 .bottom(px(0.))
286 .right(relative(1.))
287 .w(px(0.))
288 .flex()
289 .items_center()
290 .justify_end()
291 .child(deferred(div().mr(gap).child(surface)).with_priority(2))
292 .into_any_element(),
293 PopoverPlacement::End => div()
294 .absolute()
295 .top(px(0.))
296 .bottom(px(0.))
297 .left(relative(1.))
298 .w(px(0.))
299 .flex()
300 .items_center()
301 .justify_start()
302 .child(deferred(div().ml(gap).child(surface)).with_priority(2))
303 .into_any_element(),
304 };
305
306 div()
307 .relative()
308 .flex()
309 .flex_none()
310 .self_start()
311 .items_center()
312 .refine_style(&self.style)
313 .child(trigger)
314 .when(open, |el| el.child(popup))
315 }
316}
317
318#[derive(Clone, Copy, Debug, PartialEq)]
319struct PopoverChrome {
320 background: gpui::Hsla,
321 border: gpui::Hsla,
322 inset: gpui::Hsla,
323 shadow: gpui::Hsla,
324}
325
326fn popover_chrome(theme: Theme) -> PopoverChrome {
327 match theme.kind {
328 ThemeKind::Light => PopoverChrome {
329 background: paint(0xFFFFFF85),
330 border: paint(0xFFFFFFB8),
331 inset: paint(0xFFFFFFE6),
332 shadow: paint(0x0F172A0F),
333 },
334 ThemeKind::Dark => PopoverChrome {
335 background: paint(0xFFFFFF12),
336 border: paint(0xFFFFFF1A),
337 inset: paint(0xFFFFFF1F),
338 shadow: paint(0x00000047),
339 },
340 }
341}
342
343#[derive(IntoElement)]
345pub struct PopoverContent {
346 style: StyleRefinement,
347 children: Vec<AnyElement>,
348}
349
350impl PopoverContent {
351 pub fn new() -> Self {
352 Self {
353 style: StyleRefinement::default(),
354 children: Vec::new(),
355 }
356 }
357}
358
359impl Default for PopoverContent {
360 fn default() -> Self {
361 Self::new()
362 }
363}
364
365impl Styled for PopoverContent {
366 fn style(&mut self) -> &mut StyleRefinement {
367 &mut self.style
368 }
369}
370
371impl ParentElement for PopoverContent {
372 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
373 self.children.extend(elements);
374 }
375}
376
377impl RenderOnce for PopoverContent {
378 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
379 let chrome = popover_chrome(cx.theme());
380
381 div()
382 .flex()
383 .flex_col()
384 .w(px(220.))
385 .flex_shrink_0()
386 .p(px(12.))
387 .gap(px(4.))
388 .rounded(px(6.))
389 .border_1()
390 .border_color(chrome.border)
391 .bg(chrome.background)
392 .shadow(vec![
393 box_shadow(0., 1., chrome.inset, 0., 0.),
394 box_shadow(0., 6., chrome.shadow, 16., 0.),
395 ])
396 .refine_style(&self.style)
397 .children(self.children)
398 }
399}
400
401#[derive(IntoElement)]
403pub struct PopoverTitle {
404 text: SharedString,
405 style: StyleRefinement,
406}
407
408impl PopoverTitle {
409 pub fn new(text: impl Into<SharedString>) -> Self {
410 Self {
411 text: text.into(),
412 style: StyleRefinement::default(),
413 }
414 }
415}
416
417impl Styled for PopoverTitle {
418 fn style(&mut self) -> &mut StyleRefinement {
419 &mut self.style
420 }
421}
422
423impl RenderOnce for PopoverTitle {
424 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
425 let theme = cx.theme();
426 div()
427 .font_family(theme.font_family)
428 .font_weight(FontWeight::MEDIUM)
429 .text_size(px(14.))
430 .line_height(px(18.))
431 .text_color(theme.ink)
432 .refine_style(&self.style)
433 .child(self.text)
434 }
435}
436
437#[derive(IntoElement)]
439pub struct PopoverDescription {
440 text: SharedString,
441 style: StyleRefinement,
442}
443
444impl PopoverDescription {
445 pub fn new(text: impl Into<SharedString>) -> Self {
446 Self {
447 text: text.into(),
448 style: StyleRefinement::default(),
449 }
450 }
451}
452
453impl Styled for PopoverDescription {
454 fn style(&mut self) -> &mut StyleRefinement {
455 &mut self.style
456 }
457}
458
459impl RenderOnce for PopoverDescription {
460 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
461 let theme = cx.theme();
462 div()
463 .font_family(theme.font_family)
464 .font_weight(FontWeight::NORMAL)
465 .text_size(px(13.))
466 .line_height(px(18.))
467 .text_color(theme.body)
468 .refine_style(&self.style)
469 .child(self.text)
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn light_material_matches_paper() {
479 let chrome = popover_chrome(Theme::light());
480 assert_eq!(chrome.background, paint(0xFFFFFF85));
481 assert_eq!(chrome.border, paint(0xFFFFFFB8));
482 assert_eq!(chrome.inset, paint(0xFFFFFFE6));
483 assert_eq!(chrome.shadow, paint(0x0F172A0F));
484 }
485
486 #[test]
487 fn dark_material_matches_paper() {
488 let chrome = popover_chrome(Theme::dark());
489 assert_eq!(chrome.background, paint(0xFFFFFF12));
490 assert_eq!(chrome.border, paint(0xFFFFFF1A));
491 assert_eq!(chrome.inset, paint(0xFFFFFF1F));
492 assert_eq!(chrome.shadow, paint(0x00000047));
493 }
494}