Skip to main content

guise/overlay/
popover.rs

1//! `Popover` — the reusable anchored-floating primitive.
2//!
3//! A trigger plus a deferred panel positioned relative to it. This is the
4//! shared mechanism behind dropdown-style UI (`Menu`/`Select` predate it and
5//! still hand-roll their own); build `Drawer`, combobox dropdowns, and custom
6//! flyouts on top of it.
7//!
8//! Both the trigger and the content are **builder closures**, re-invoked each
9//! render so they can show live data. The popover closes on Escape or a second
10//! trigger click; call [`Popover::close`] from a content action to dismiss it.
11
12use gpui::prelude::*;
13use gpui::{
14    deferred, div, px, relative, AnyElement, App, Context, FocusHandle, IntoElement, KeyDownEvent,
15    Window,
16};
17
18use crate::theme::theme;
19
20type Builder = Box<dyn Fn(&mut Window, &mut App) -> AnyElement + 'static>;
21
22/// Where the panel sits relative to its trigger.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Placement {
25    /// Below, left-aligned (the default).
26    Bottom,
27    /// Below, right-aligned.
28    BottomEnd,
29    /// Above, left-aligned.
30    Top,
31    /// Above, right-aligned.
32    TopEnd,
33}
34
35/// An anchored floating panel. Create with `cx.new(|cx| Popover::new(cx, ..))`.
36pub struct Popover {
37    open: bool,
38    focus: FocusHandle,
39    trigger: Builder,
40    content: Builder,
41    placement: Placement,
42    width: Option<f32>,
43}
44
45impl Popover {
46    pub fn new(
47        cx: &mut Context<Self>,
48        trigger: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
49        content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
50    ) -> Self {
51        Popover {
52            open: false,
53            focus: cx.focus_handle(),
54            trigger: Box::new(trigger),
55            content: Box::new(content),
56            placement: Placement::Bottom,
57            width: None,
58        }
59    }
60
61    pub fn placement(mut self, placement: Placement) -> Self {
62        self.placement = placement;
63        self
64    }
65
66    /// Fix the panel width (otherwise it sizes to content, min 180px).
67    pub fn width(mut self, width: f32) -> Self {
68        self.width = Some(width);
69        self
70    }
71
72    pub fn is_open(&self) -> bool {
73        self.open
74    }
75
76    pub fn open(&mut self, cx: &mut Context<Self>) {
77        self.open = true;
78        cx.notify();
79    }
80
81    pub fn close(&mut self, cx: &mut Context<Self>) {
82        self.open = false;
83        cx.notify();
84    }
85
86    pub fn toggle(&mut self, cx: &mut Context<Self>) {
87        self.open = !self.open;
88        cx.notify();
89    }
90}
91
92impl Render for Popover {
93    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
94        let t = theme(cx);
95        let radius = t.radius(t.default_radius);
96        let surface = t.surface().hsla();
97        let border = t.border().hsla();
98
99        let trigger_el = (self.trigger)(window, cx);
100        let mut wrap = div().relative().child(
101            div()
102                .id("guise-popover-trigger")
103                .track_focus(&self.focus)
104                .on_key_down(cx.listener(|this, ev: &KeyDownEvent, _window, cx| {
105                    if ev.keystroke.key.as_str() == "escape" {
106                        this.open = false;
107                        cx.notify();
108                    }
109                }))
110                .on_click(cx.listener(|this, _ev, window, cx| {
111                    this.open = !this.open;
112                    window.focus(&this.focus);
113                    cx.notify();
114                }))
115                .child(trigger_el),
116        );
117
118        if self.open {
119            let content_el = (self.content)(window, cx);
120            let base = div()
121                .absolute()
122                .flex()
123                .flex_col()
124                .p(px(4.0))
125                .rounded(px(radius))
126                .border_1()
127                .border_color(border)
128                .bg(surface)
129                .shadow_md()
130                .child(content_el);
131            let placed = match self.placement {
132                Placement::Bottom => base.top(relative(1.0)).mt(px(6.0)).left(px(0.0)),
133                Placement::BottomEnd => base.top(relative(1.0)).mt(px(6.0)).right(px(0.0)),
134                Placement::Top => base.bottom(relative(1.0)).mb(px(6.0)).left(px(0.0)),
135                Placement::TopEnd => base.bottom(relative(1.0)).mb(px(6.0)).right(px(0.0)),
136            };
137            let placed = match self.width {
138                Some(w) => placed.w(px(w)),
139                None => placed.min_w(px(180.0)),
140            };
141            wrap = wrap.child(deferred(placed));
142        }
143
144        wrap
145    }
146}