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(align.place_child(self.anchor, self.size, self.gap), viewport)
175    }
176
177    /// Whether `pos` falls inside the placed popup rect.
178    pub fn contains(&self, pos: Point, viewport: Size) -> bool {
179        let r = self.rect(viewport);
180        pos.x >= r.x && pos.x <= r.x + r.width && pos.y >= r.y && pos.y <= r.y + r.height
181    }
182
183    /// Close on Escape, regardless of [`PopupCloseBehavior`] — egui closes any
184    /// popup on Escape (popup.rs `should_close`), so even an `IgnoreClicks`
185    /// popup stays keyboard-dismissable. Returns `true` if this call closed
186    /// the popup (callers should consume the key event then).
187    pub fn on_escape(&mut self) -> bool {
188        if self.open {
189            self.open = false;
190            true
191        } else {
192            false
193        }
194    }
195
196    /// Apply the close behavior to a left mouse-down at `pos`.
197    ///
198    /// Returns the [`PopupClickOutcome`] so the host knows whether the popup
199    /// closed and whether to consume the event. A no-op (returns all-false)
200    /// when the popup is already closed.
201    pub fn on_mouse_down(&mut self, pos: Point, viewport: Size) -> PopupClickOutcome {
202        if !self.open {
203            return PopupClickOutcome::default();
204        }
205        let inside = self.contains(pos, viewport);
206        let closed = match self.close_behavior {
207            PopupCloseBehavior::CloseOnClick => true,
208            PopupCloseBehavior::CloseOnClickOutside => !inside,
209            PopupCloseBehavior::IgnoreClicks => false,
210        };
211        if closed {
212            self.open = false;
213        }
214        // Swallow the event whenever the click was inside the popup (so it
215        // doesn't fall through to whatever is behind it) or whenever it caused
216        // the popup to close.
217        let consumed = inside || closed;
218        PopupClickOutcome {
219            inside,
220            closed,
221            consumed,
222        }
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn popup(behavior: PopupCloseBehavior) -> Popup {
231        let mut p = Popup::new();
232        p.close_behavior = behavior;
233        p.align = RectAlign::BOTTOM_START;
234        p.gap = 4.0;
235        p.set_anchor(Rect::new(100.0, 200.0, 40.0, 20.0));
236        p.set_size(Size::new(80.0, 60.0));
237        p.open();
238        p
239    }
240
241    const VIEWPORT: Size = Size {
242        width: 800.0,
243        height: 600.0,
244    };
245
246    fn inside_point(p: &Popup) -> Point {
247        let r = p.rect(VIEWPORT);
248        Point::new(r.x + r.width * 0.5, r.y + r.height * 0.5)
249    }
250
251    #[test]
252    fn close_on_click_closes_from_inside_or_outside() {
253        let mut p = popup(PopupCloseBehavior::CloseOnClick);
254        let inside = inside_point(&p);
255        let out = p.on_mouse_down(inside, VIEWPORT);
256        assert!(out.inside && out.closed && out.consumed);
257        assert!(!p.is_open());
258
259        let mut p = popup(PopupCloseBehavior::CloseOnClick);
260        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
261        assert!(!out.inside && out.closed && out.consumed);
262        assert!(!p.is_open());
263    }
264
265    #[test]
266    fn close_on_click_outside_keeps_open_when_inside() {
267        let mut p = popup(PopupCloseBehavior::CloseOnClickOutside);
268        let inside = inside_point(&p);
269        let out = p.on_mouse_down(inside, VIEWPORT);
270        assert!(out.inside && !out.closed && out.consumed);
271        assert!(p.is_open(), "click inside keeps a CloseOnClickOutside popup open");
272
273        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
274        assert!(!out.inside && out.closed && out.consumed);
275        assert!(!p.is_open());
276    }
277
278    #[test]
279    fn ignore_clicks_never_closes_but_swallows_inside_clicks() {
280        let mut p = popup(PopupCloseBehavior::IgnoreClicks);
281        let inside = inside_point(&p);
282        let out = p.on_mouse_down(inside, VIEWPORT);
283        assert!(out.inside && !out.closed && out.consumed);
284        assert!(p.is_open());
285
286        // Outside click falls through and leaves it open.
287        let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
288        assert!(!out.inside && !out.closed && !out.consumed);
289        assert!(p.is_open());
290    }
291
292    #[test]
293    fn closed_popup_ignores_clicks() {
294        let mut p = popup(PopupCloseBehavior::CloseOnClick);
295        p.close();
296        let out = p.on_mouse_down(Point::new(120.0, 190.0), VIEWPORT);
297        assert_eq!(out, PopupClickOutcome::default());
298    }
299
300    #[test]
301    fn behavior_table_indices_round_trip() {
302        for (i, (b, _, _)) in PopupCloseBehavior::ALL.iter().enumerate() {
303            assert_eq!(b.all_index(), i);
304        }
305    }
306
307    #[test]
308    fn default_close_behavior_matches_egui() {
309        assert_eq!(PopupCloseBehavior::default(), PopupCloseBehavior::CloseOnClick);
310        assert_eq!(Popup::default().close_behavior, PopupCloseBehavior::CloseOnClick);
311    }
312
313    #[test]
314    fn escape_closes_even_ignore_clicks_popups() {
315        let mut p = popup(PopupCloseBehavior::IgnoreClicks);
316        assert!(p.is_open());
317        assert!(p.on_escape(), "escape must close an IgnoreClicks popup");
318        assert!(!p.is_open());
319        assert!(!p.on_escape(), "second escape is a no-op");
320    }
321
322    #[test]
323    fn rect_flips_to_opposite_side_when_anchor_hugs_an_edge() {
324        // Anchor at the bottom of the screen (Y-up: y near 0): BOTTOM_START
325        // would overflow below, so the effective align flips to TOP_START and
326        // the popup opens above the anchor instead of merely clamping over it.
327        let mut p = popup(PopupCloseBehavior::CloseOnClick);
328        p.set_anchor(Rect::new(100.0, 10.0, 40.0, 20.0));
329        assert_eq!(p.effective_align(VIEWPORT), RectAlign::TOP_START);
330        let r = p.rect(VIEWPORT);
331        assert_eq!(r.y, 10.0 + 20.0 + p.gap, "popup bottom sits gap above the anchor top");
332
333        // Plenty of room: the configured align is kept.
334        p.set_anchor(Rect::new(100.0, 300.0, 40.0, 20.0));
335        assert_eq!(p.effective_align(VIEWPORT), RectAlign::BOTTOM_START);
336    }
337}