Skip to main content

elegance/
popover.rs

1//! Popover — a click-anchored floating panel that points at a trigger.
2//!
3//! A [`Popover`] opens a themed, bordered panel next to a trigger
4//! [`Response`] when the trigger is clicked. The panel has an optional
5//! title row, a free-form body (filled by the caller's closure), and a
6//! small arrow that visually connects the panel to the trigger. Close
7//! behaviour matches the platform convention: click outside, press
8//! `Esc`, or click the trigger again.
9//!
10//! ```no_run
11//! # use elegance::{Button, Popover, PopoverSide};
12//! # egui::__run_test_ui(|ui| {
13//! let trigger = ui.add(Button::new("Filter"));
14//! Popover::new("filters")
15//!     .side(PopoverSide::Bottom)
16//!     .title("Filter results")
17//!     .show(&trigger, |ui| {
18//!         ui.label("…");
19//!     });
20//! # });
21//! ```
22//!
23//! Popovers are lighter than [`Modal`](crate::Modal): they don't dim the
24//! background or trap focus. Reach for a [`Modal`](crate::Modal) when the
25//! user must respond before continuing; reach for a [`Popover`] for
26//! inline settings, confirmations, or rich hover-cards.
27
28use std::hash::Hash;
29
30use egui::{
31    emath::RectAlign, Color32, CornerRadius, Frame, Id, InnerResponse, Margin, Pos2, Rect,
32    Response, Shape, Stroke, Ui, Vec2, WidgetText,
33};
34
35use crate::theme::Theme;
36
37/// Which side of the trigger the popover opens on.
38///
39/// The popover will try the requested side first and fall back to the
40/// opposite side if the requested placement doesn't fit in the viewport.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum PopoverSide {
43    /// Opens above the trigger; arrow points down.
44    Top,
45    /// Opens below the trigger; arrow points up. The default.
46    Bottom,
47    /// Opens to the left of the trigger; arrow points right.
48    Left,
49    /// Opens to the right of the trigger; arrow points left.
50    Right,
51}
52
53impl PopoverSide {
54    fn to_rect_align(self) -> RectAlign {
55        match self {
56            PopoverSide::Top => RectAlign::TOP,
57            PopoverSide::Bottom => RectAlign::BOTTOM,
58            PopoverSide::Left => RectAlign::LEFT,
59            PopoverSide::Right => RectAlign::RIGHT,
60        }
61    }
62}
63
64/// A click-to-toggle popover anchored to a trigger [`Response`].
65///
66/// Call [`Popover::show`] immediately after painting the trigger; the
67/// popover toggles open on trigger clicks and closes on outside-click,
68/// `Esc`, or a subsequent trigger click.
69#[derive(Debug, Clone)]
70#[must_use = "Call `.show(&trigger, |ui| ...)` to render the popover."]
71pub struct Popover {
72    id_salt: Id,
73    side: PopoverSide,
74    title: Option<WidgetText>,
75    width: Option<f32>,
76    min_width: f32,
77    gap: f32,
78    arrow: bool,
79}
80
81impl Popover {
82    /// Create a popover keyed by `id_salt`. The salt is used to persist
83    /// the open/closed state across frames and must be stable for the
84    /// trigger it's attached to.
85    pub fn new(id_salt: impl Hash) -> Self {
86        Self {
87            id_salt: Self::popup_id(id_salt),
88            side: PopoverSide::Bottom,
89            title: None,
90            width: None,
91            min_width: 200.0,
92            gap: 8.0,
93            arrow: true,
94        }
95    }
96
97    /// The internal popup id for a given `id_salt`.
98    ///
99    /// Use this with [`egui::Popup::open_id`] / [`egui::Popup::close_id`]
100    /// to open or close a popover programmatically (for example, from a
101    /// keyboard shortcut or a test harness).
102    pub fn popup_id(id_salt: impl Hash) -> Id {
103        Id::new(("elegance::popover", Id::new(id_salt)))
104    }
105
106    /// Which side of the trigger to anchor on. Default: [`PopoverSide::Bottom`].
107    #[inline]
108    pub fn side(mut self, side: PopoverSide) -> Self {
109        self.side = side;
110        self
111    }
112
113    /// Add a strong title row above the body.
114    #[inline]
115    pub fn title(mut self, title: impl Into<WidgetText>) -> Self {
116        self.title = Some(title.into());
117        self
118    }
119
120    /// Fix the popover's content width. When unset, the popover sizes
121    /// itself to the content and its `min_width`.
122    #[inline]
123    pub fn width(mut self, width: f32) -> Self {
124        self.width = Some(width);
125        self
126    }
127
128    /// Minimum content width in points. Default: 200.
129    #[inline]
130    pub fn min_width(mut self, min_width: f32) -> Self {
131        self.min_width = min_width;
132        self
133    }
134
135    /// Gap between the trigger and the popover, in points. Default: 8.
136    #[inline]
137    pub fn gap(mut self, gap: f32) -> Self {
138        self.gap = gap;
139        self
140    }
141
142    /// Toggle the small arrow that points at the trigger. Default: on.
143    #[inline]
144    pub fn arrow(mut self, arrow: bool) -> Self {
145        self.arrow = arrow;
146        self
147    }
148
149    /// Render the popover attached to `trigger`. Returns `Some` with the
150    /// body closure's return value while the popover is open, `None`
151    /// while it is closed.
152    pub fn show<R>(
153        self,
154        trigger: &Response,
155        add_contents: impl FnOnce(&mut Ui) -> R,
156    ) -> Option<InnerResponse<R>> {
157        let theme = Theme::current(&trigger.ctx);
158        let p = &theme.palette;
159
160        let popup_id = self.id_salt;
161        let side = self.side;
162        let title = self.title;
163        let arrow = self.arrow;
164        let width = self.width;
165        let min_width = self.min_width;
166
167        let frame = Frame::new()
168            .fill(p.card)
169            .stroke(Stroke::new(1.0, p.border))
170            .corner_radius(CornerRadius::same(theme.card_radius as u8))
171            .inner_margin(Margin::same(12));
172
173        let mut popup = egui::Popup::from_toggle_button_response(trigger)
174            .id(popup_id)
175            .align(side.to_rect_align())
176            .align_alternatives(&[])
177            .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
178            .gap(self.gap)
179            .frame(frame);
180        if let Some(w) = width {
181            popup = popup.width(w);
182        }
183
184        let trigger_rect = trigger.rect;
185        let trigger_ctx = trigger.ctx.clone();
186
187        let response = popup.show(move |ui| {
188            ui.set_min_width(min_width);
189            if let Some(h) = &title {
190                let t = Theme::current(ui.ctx());
191                let rt = egui::RichText::new(h.text())
192                    .color(t.palette.text)
193                    .size(t.typography.body)
194                    .strong();
195                ui.add(egui::Label::new(rt).wrap_mode(egui::TextWrapMode::Extend));
196                ui.add_space(4.0);
197            }
198            add_contents(ui)
199        });
200
201        let inner = response?;
202
203        if arrow {
204            // Determine the actual side used. When the popup runs out of
205            // room on the requested side egui flips it; infer the side
206            // from the popup rect vs. the trigger rect.
207            let actual_side = detect_side(trigger_rect, inner.response.rect, side);
208            paint_arrow(
209                &trigger_ctx,
210                inner.response.layer_id,
211                inner.response.rect,
212                trigger_rect,
213                actual_side,
214                p.card,
215                p.border,
216            );
217        }
218
219        Some(inner)
220    }
221}
222
223fn detect_side(trigger: Rect, popup: Rect, requested: PopoverSide) -> PopoverSide {
224    match requested {
225        PopoverSide::Top | PopoverSide::Bottom => {
226            if popup.center().y < trigger.center().y {
227                PopoverSide::Top
228            } else {
229                PopoverSide::Bottom
230            }
231        }
232        PopoverSide::Left | PopoverSide::Right => {
233            if popup.center().x < trigger.center().x {
234                PopoverSide::Left
235            } else {
236                PopoverSide::Right
237            }
238        }
239    }
240}
241
242fn paint_arrow(
243    ctx: &egui::Context,
244    layer: egui::LayerId,
245    popup: Rect,
246    trigger: Rect,
247    side: PopoverSide,
248    fill: Color32,
249    border: Color32,
250) {
251    let painter = ctx.layer_painter(layer);
252
253    // Half-base and height of the isoceles arrow triangle.
254    let half_base = 6.0;
255    let depth = 6.0;
256    let inset = 10.0; // Keep the arrow away from the rounded corner.
257
258    // Axis along which the arrow's base runs (`base_center` lies on the
259    // popup edge adjacent to the trigger), the perpendicular direction
260    // (pointing from the popup toward the trigger) and the base-axis
261    // direction used to lay out the triangle's footprint.
262    let (base_center, perp, base_axis) = match side {
263        PopoverSide::Bottom => {
264            let cx = trigger
265                .center()
266                .x
267                .clamp(popup.min.x + inset, popup.max.x - inset);
268            (
269                Pos2::new(cx, popup.min.y),
270                Vec2::new(0.0, -1.0),
271                Vec2::new(1.0, 0.0),
272            )
273        }
274        PopoverSide::Top => {
275            let cx = trigger
276                .center()
277                .x
278                .clamp(popup.min.x + inset, popup.max.x - inset);
279            (
280                Pos2::new(cx, popup.max.y),
281                Vec2::new(0.0, 1.0),
282                Vec2::new(1.0, 0.0),
283            )
284        }
285        PopoverSide::Right => {
286            let cy = trigger
287                .center()
288                .y
289                .clamp(popup.min.y + inset, popup.max.y - inset);
290            (
291                Pos2::new(popup.min.x, cy),
292                Vec2::new(-1.0, 0.0),
293                Vec2::new(0.0, 1.0),
294            )
295        }
296        PopoverSide::Left => {
297            let cy = trigger
298                .center()
299                .y
300                .clamp(popup.min.y + inset, popup.max.y - inset);
301            (
302                Pos2::new(popup.max.x, cy),
303                Vec2::new(1.0, 0.0),
304                Vec2::new(0.0, 1.0),
305            )
306        }
307    };
308
309    let base_a = base_center + base_axis * half_base;
310    let base_b = base_center - base_axis * half_base;
311    let tip = base_center + perp * depth;
312
313    // 1. Filled triangle in the popup fill colour, extending outside the
314    //    popup edge toward the trigger.
315    painter.add(Shape::convex_polygon(
316        vec![base_a, tip, base_b],
317        fill,
318        Stroke::NONE,
319    ));
320
321    // 2. Cover the popup's border stroke where the arrow meets it: a
322    //    short segment in the fill colour along the popup edge.
323    painter.line_segment([base_a, base_b], Stroke::new(1.5, fill));
324
325    // 3. Stroke the two outward edges of the triangle in the border colour.
326    let stroke = Stroke::new(1.0, border);
327    painter.line_segment([base_a, tip], stroke);
328    painter.line_segment([base_b, tip], stroke);
329}