Skip to main content

agg_gui/widgets/popup/
behavior.rs

1//! Open-state and close-behavior controller for anchored popups.
2//!
3//! [`Popup`] is a small, content-agnostic controller: it owns the open flag,
4//! the anchor rectangle, the [`RectAlign`] placement, the pixel gap, and the
5//! [`PopupCloseBehavior`]. A host widget drives it — feeds the anchor and the
6//! measured content size, asks for the placed rect to paint, and forwards mouse
7//! events so the controller can apply the close behavior.
8//!
9//! This deliberately does **not** re-implement overlay painting, hit testing,
10//! keyboard navigation, or submenu cascades — the menu system
11//! (`super::super::menu`) already owns all of that. When a popup needs rich
12//! nested-menu content, host it with a [`PopupMenu`](super::super::menu::PopupMenu),
13//! which reuses the whole menu machinery. `Popup` adds the one thing the menu
14//! system can't express on its own: arbitrary [`RectAlign`] placement plus a
15//! selectable close behavior.
16
17use crate::geometry::{Point, Rect, Size};
18
19use super::align::{clamp_rect, RectAlign};
20
21/// When a popup should dismiss itself in response to clicks. Mirrors egui's
22/// `PopupCloseBehavior`.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PopupCloseBehavior {
25    /// Close as soon as the user clicks anything — inside or outside the popup.
26    /// Suits menu-style popups where any activation dismisses the menu.
27    CloseOnClick,
28    /// Close only when the user clicks outside the popup. Clicks inside are
29    /// captured but keep it open — good for popups with interactive content.
30    CloseOnClickOutside,
31    /// Never close on a click; only an explicit toggle / [`Popup::close`] does.
32    IgnoreClicks,
33}
34
35impl Default for PopupCloseBehavior {
36    /// `CloseOnClick`, matching egui's `#[default]` on its
37    /// `PopupCloseBehavior` (used by combo boxes and menus).
38    fn default() -> Self {
39        Self::CloseOnClick
40    }
41}
42
43impl PopupCloseBehavior {
44    /// The three behaviors in a stable order with a label and the same
45    /// one-line explanation egui's Popups demo shows as a hover tooltip.
46    pub const ALL: [(Self, &'static str, &'static str); 3] = [
47        (
48            Self::CloseOnClick,
49            "CloseOnClick",
50            "Closes when the user clicks anywhere (inside or outside)",
51        ),
52        (
53            Self::CloseOnClickOutside,
54            "CloseOnClickOutside",
55            "Closes when the user clicks outside the popup",
56        ),
57        (
58            Self::IgnoreClicks,
59            "IgnoreClicks",
60            "Close only when the button is clicked again",
61        ),
62    ];
63
64    /// Index of this behavior within [`PopupCloseBehavior::ALL`].
65    pub fn all_index(self) -> usize {
66        Self::ALL
67            .iter()
68            .position(|(b, _, _)| *b == self)
69            .unwrap_or(0)
70    }
71}
72
73/// What a forwarded mouse-down did to a popup.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
75pub struct PopupClickOutcome {
76    /// The click landed inside the popup's placed rect.
77    pub inside: bool,
78    /// The click closed the popup.
79    pub closed: bool,
80    /// The host should treat the event as consumed (don't pass it through).
81    pub consumed: bool,
82}
83
84/// Controller for a single anchored popup surface.
85#[derive(Clone, Debug)]
86pub struct Popup {
87    open: bool,
88    anchor: Rect,
89    size: Size,
90    pub align: RectAlign,
91    pub gap: f64,
92    pub close_behavior: PopupCloseBehavior,
93}
94
95impl Default for Popup {
96    fn default() -> Self {
97        Self {
98            open: false,
99            anchor: Rect::default(),
100            size: Size::new(0.0, 0.0),
101            align: RectAlign::BOTTOM_START,
102            gap: 4.0,
103            close_behavior: PopupCloseBehavior::default(),
104        }
105    }
106}
107
108impl Popup {
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    pub fn is_open(&self) -> bool {
114        self.open
115    }
116
117    pub fn open(&mut self) {
118        self.open = true;
119    }
120
121    pub fn close(&mut self) {
122        self.open = false;
123    }
124
125    pub fn set_open(&mut self, open: bool) {
126        self.open = open;
127    }
128
129    /// Flip the open flag; returns the new state.
130    pub fn toggle(&mut self) -> bool {
131        self.open = !self.open;
132        self.open
133    }
134
135    /// Record the anchor rect (the opener widget's bounds, in the coordinate
136    /// space the popup is placed and hit-tested in).
137    pub fn set_anchor(&mut self, anchor: Rect) {
138        self.anchor = anchor;
139    }
140
141    pub fn anchor(&self) -> Rect {
142        self.anchor
143    }
144
145    /// Record the measured content size used for placement.
146    pub fn set_size(&mut self, size: Size) {
147        self.size = size;
148    }
149
150    /// The placement actually used this frame: the configured `align` if the
151    /// popup fits on-screen with it, otherwise the first of its
152    /// [`RectAlign::symmetries`] (then [`RectAlign::MENU_ALIGNS`]) that does —
153    /// egui's overflow-flip (`Popup::get_best_align` in
154    /// egui/src/containers/popup.rs).
155    pub fn effective_align(&self, viewport: Size) -> RectAlign {
156        let content = Rect::new(0.0, 0.0, viewport.width, viewport.height);
157        RectAlign::find_best_align(
158            std::iter::once(self.align)
159                .chain(self.align.symmetries())
160                .chain(RectAlign::MENU_ALIGNS),
161            content,
162            self.anchor,
163            self.gap,
164            self.size,
165        )
166        .unwrap_or(self.align)
167    }
168
169    /// The placed popup rect: best-fit alignment first (overflow flip), then
170    /// clamped to the viewport as the final fallback when even the best
171    /// candidate overflows.
172    pub fn rect(&self, viewport: Size) -> Rect {
173        let align = self.effective_align(viewport);
174        clamp_rect(
175            align.place_child(self.anchor, self.size, self.gap),
176            viewport,
177        )
178    }
179
180    /// Whether `pos` falls inside the placed popup rect.
181    pub fn contains(&self, pos: Point, viewport: Size) -> bool {
182        let r = self.rect(viewport);
183        pos.x >= r.x && pos.x <= r.x + r.width && pos.y >= r.y && pos.y <= r.y + r.height
184    }
185
186    /// Close on Escape, regardless of [`PopupCloseBehavior`] — egui closes any
187    /// popup on Escape (popup.rs `should_close`), so even an `IgnoreClicks`
188    /// popup stays keyboard-dismissable. Returns `true` if this call closed
189    /// the popup (callers should consume the key event then).
190    pub fn on_escape(&mut self) -> bool {
191        if self.open {
192            self.open = false;
193            true
194        } else {
195            false
196        }
197    }
198
199    /// Apply the close behavior to a left mouse-down at `pos`.
200    ///
201    /// Returns the [`PopupClickOutcome`] so the host knows whether the popup
202    /// closed and whether to consume the event. A no-op (returns all-false)
203    /// when the popup is already closed.
204    pub fn on_mouse_down(&mut self, pos: Point, viewport: Size) -> PopupClickOutcome {
205        if !self.open {
206            return PopupClickOutcome::default();
207        }
208        let inside = self.contains(pos, viewport);
209        let closed = match self.close_behavior {
210            PopupCloseBehavior::CloseOnClick => true,
211            PopupCloseBehavior::CloseOnClickOutside => !inside,
212            PopupCloseBehavior::IgnoreClicks => false,
213        };
214        if closed {
215            self.open = false;
216        }
217        // Swallow the event whenever the click was inside the popup (so it
218        // doesn't fall through to whatever is behind it) or whenever it caused
219        // the popup to close.
220        let consumed = inside || closed;
221        PopupClickOutcome {
222            inside,
223            closed,
224            consumed,
225        }
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    fn popup(behavior: PopupCloseBehavior) -> Popup {
234        let mut p = Popup::new();
235        p.close_behavior = behavior;
236        p.align = RectAlign::BOTTOM_START;
237        p.gap = 4.0;
238        p.set_anchor(Rect::new(100.0, 200.0, 40.0, 20.0));
239        p.set_size(Size::new(80.0, 60.0));
240        p.open();
241        p
242    }
243
244    const VIEWPORT: Size = Size {
245        width: 800.0,
246        height: 600.0,
247    };
248
249    fn inside_point(p: &Popup) -> Point {
250        let r = p.rect(VIEWPORT);
251        Point::new(r.x + r.width * 0.5, r.y + r.height * 0.5)
252    }
253
254    #[test]
255    fn close_on_click_closes_from_inside_or_outside() {
256        let mut p = popup(PopupCloseBehavior::CloseOnClick);
257        let inside = inside_point(&p);
258        let out = p.on_mouse_down(inside, VIEWPORT);
259        assert!(out.inside && out.closed && out.consumed);
260        assert!(!p.is_open());
261
262        let mut p = popup(PopupCloseBehavior::CloseOnClick);
263        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
264        assert!(!out.inside && out.closed && out.consumed);
265        assert!(!p.is_open());
266    }
267
268    #[test]
269    fn close_on_click_outside_keeps_open_when_inside() {
270        let mut p = popup(PopupCloseBehavior::CloseOnClickOutside);
271        let inside = inside_point(&p);
272        let out = p.on_mouse_down(inside, VIEWPORT);
273        assert!(out.inside && !out.closed && out.consumed);
274        assert!(
275            p.is_open(),
276            "click inside keeps a CloseOnClickOutside popup open"
277        );
278
279        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
280        assert!(!out.inside && out.closed && out.consumed);
281        assert!(!p.is_open());
282    }
283
284    #[test]
285    fn ignore_clicks_never_closes_but_swallows_inside_clicks() {
286        let mut p = popup(PopupCloseBehavior::IgnoreClicks);
287        let inside = inside_point(&p);
288        let out = p.on_mouse_down(inside, VIEWPORT);
289        assert!(out.inside && !out.closed && out.consumed);
290        assert!(p.is_open());
291
292        // Outside click falls through and leaves it open.
293        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
294        assert!(!out.inside && !out.closed && !out.consumed);
295        assert!(p.is_open());
296    }
297
298    #[test]
299    fn closed_popup_ignores_clicks() {
300        let mut p = popup(PopupCloseBehavior::CloseOnClick);
301        p.close();
302        let out = p.on_mouse_down(Point::new(120.0, 190.0), VIEWPORT);
303        assert_eq!(out, PopupClickOutcome::default());
304    }
305
306    #[test]
307    fn behavior_table_indices_round_trip() {
308        for (i, (b, _, _)) in PopupCloseBehavior::ALL.iter().enumerate() {
309            assert_eq!(b.all_index(), i);
310        }
311    }
312
313    #[test]
314    fn default_close_behavior_matches_egui() {
315        assert_eq!(
316            PopupCloseBehavior::default(),
317            PopupCloseBehavior::CloseOnClick
318        );
319        assert_eq!(
320            Popup::default().close_behavior,
321            PopupCloseBehavior::CloseOnClick
322        );
323    }
324
325    #[test]
326    fn escape_closes_even_ignore_clicks_popups() {
327        let mut p = popup(PopupCloseBehavior::IgnoreClicks);
328        assert!(p.is_open());
329        assert!(p.on_escape(), "escape must close an IgnoreClicks popup");
330        assert!(!p.is_open());
331        assert!(!p.on_escape(), "second escape is a no-op");
332    }
333
334    #[test]
335    fn rect_flips_to_opposite_side_when_anchor_hugs_an_edge() {
336        // Anchor at the bottom of the screen (Y-up: y near 0): BOTTOM_START
337        // would overflow below, so the effective align flips to TOP_START and
338        // the popup opens above the anchor instead of merely clamping over it.
339        let mut p = popup(PopupCloseBehavior::CloseOnClick);
340        p.set_anchor(Rect::new(100.0, 10.0, 40.0, 20.0));
341        assert_eq!(p.effective_align(VIEWPORT), RectAlign::TOP_START);
342        let r = p.rect(VIEWPORT);
343        assert_eq!(
344            r.y,
345            10.0 + 20.0 + p.gap,
346            "popup bottom sits gap above the anchor top"
347        );
348
349        // Plenty of room: the configured align is kept.
350        p.set_anchor(Rect::new(100.0, 300.0, 40.0, 20.0));
351        assert_eq!(p.effective_align(VIEWPORT), RectAlign::BOTTOM_START);
352    }
353}