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(align.place_child(self.anchor, self.size, self.gap), viewport)
175 }
176
177 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 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 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 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 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 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 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}