Skip to main content

glassy_ui/
popover.rs

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/// Trigger-anchored non-modal content with click, Escape, and outside dismissal.
84#[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/// Secondary-glass popover panel matching Paper `Glassy UI` → Popovers.
344#[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/// Medium page-meta heading used inside a [`PopoverContent`].
402#[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/// Supporting page metadata used inside a [`PopoverContent`].
438#[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}