1use crate::geometry::{Point, Rect, Size};
18
19use super::align::{clamp_rect, RectAlign};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum PopupCloseBehavior {
25 CloseOnClick,
28 CloseOnClickOutside,
31 IgnoreClicks,
33}
34
35impl Default for PopupCloseBehavior {
36 fn default() -> Self {
39 Self::CloseOnClick
40 }
41}
42
43impl PopupCloseBehavior {
44 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 pub fn all_index(self) -> usize {
66 Self::ALL
67 .iter()
68 .position(|(b, _, _)| *b == self)
69 .unwrap_or(0)
70 }
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
75pub struct PopupClickOutcome {
76 pub inside: bool,
78 pub closed: bool,
80 pub consumed: bool,
82}
83
84#[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 pub fn toggle(&mut self) -> bool {
131 self.open = !self.open;
132 self.open
133 }
134
135 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 pub fn set_size(&mut self, size: Size) {
147 self.size = size;
148 }
149
150 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 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 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 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 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 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 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 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 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}