use crate::geometry::{Point, Rect, Size};
use super::align::{clamp_rect, RectAlign};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PopupCloseBehavior {
CloseOnClick,
CloseOnClickOutside,
IgnoreClicks,
}
impl Default for PopupCloseBehavior {
fn default() -> Self {
Self::CloseOnClick
}
}
impl PopupCloseBehavior {
pub const ALL: [(Self, &'static str, &'static str); 3] = [
(
Self::CloseOnClick,
"CloseOnClick",
"Closes when the user clicks anywhere (inside or outside)",
),
(
Self::CloseOnClickOutside,
"CloseOnClickOutside",
"Closes when the user clicks outside the popup",
),
(
Self::IgnoreClicks,
"IgnoreClicks",
"Close only when the button is clicked again",
),
];
pub fn all_index(self) -> usize {
Self::ALL
.iter()
.position(|(b, _, _)| *b == self)
.unwrap_or(0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct PopupClickOutcome {
pub inside: bool,
pub closed: bool,
pub consumed: bool,
}
#[derive(Clone, Debug)]
pub struct Popup {
open: bool,
anchor: Rect,
size: Size,
pub align: RectAlign,
pub gap: f64,
pub close_behavior: PopupCloseBehavior,
}
impl Default for Popup {
fn default() -> Self {
Self {
open: false,
anchor: Rect::default(),
size: Size::new(0.0, 0.0),
align: RectAlign::BOTTOM_START,
gap: 4.0,
close_behavior: PopupCloseBehavior::default(),
}
}
}
impl Popup {
pub fn new() -> Self {
Self::default()
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self) {
self.open = true;
}
pub fn close(&mut self) {
self.open = false;
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
pub fn toggle(&mut self) -> bool {
self.open = !self.open;
self.open
}
pub fn set_anchor(&mut self, anchor: Rect) {
self.anchor = anchor;
}
pub fn anchor(&self) -> Rect {
self.anchor
}
pub fn set_size(&mut self, size: Size) {
self.size = size;
}
pub fn effective_align(&self, viewport: Size) -> RectAlign {
let content = Rect::new(0.0, 0.0, viewport.width, viewport.height);
RectAlign::find_best_align(
std::iter::once(self.align)
.chain(self.align.symmetries())
.chain(RectAlign::MENU_ALIGNS),
content,
self.anchor,
self.gap,
self.size,
)
.unwrap_or(self.align)
}
pub fn rect(&self, viewport: Size) -> Rect {
let align = self.effective_align(viewport);
clamp_rect(align.place_child(self.anchor, self.size, self.gap), viewport)
}
pub fn contains(&self, pos: Point, viewport: Size) -> bool {
let r = self.rect(viewport);
pos.x >= r.x && pos.x <= r.x + r.width && pos.y >= r.y && pos.y <= r.y + r.height
}
pub fn on_escape(&mut self) -> bool {
if self.open {
self.open = false;
true
} else {
false
}
}
pub fn on_mouse_down(&mut self, pos: Point, viewport: Size) -> PopupClickOutcome {
if !self.open {
return PopupClickOutcome::default();
}
let inside = self.contains(pos, viewport);
let closed = match self.close_behavior {
PopupCloseBehavior::CloseOnClick => true,
PopupCloseBehavior::CloseOnClickOutside => !inside,
PopupCloseBehavior::IgnoreClicks => false,
};
if closed {
self.open = false;
}
let consumed = inside || closed;
PopupClickOutcome {
inside,
closed,
consumed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn popup(behavior: PopupCloseBehavior) -> Popup {
let mut p = Popup::new();
p.close_behavior = behavior;
p.align = RectAlign::BOTTOM_START;
p.gap = 4.0;
p.set_anchor(Rect::new(100.0, 200.0, 40.0, 20.0));
p.set_size(Size::new(80.0, 60.0));
p.open();
p
}
const VIEWPORT: Size = Size {
width: 800.0,
height: 600.0,
};
fn inside_point(p: &Popup) -> Point {
let r = p.rect(VIEWPORT);
Point::new(r.x + r.width * 0.5, r.y + r.height * 0.5)
}
#[test]
fn close_on_click_closes_from_inside_or_outside() {
let mut p = popup(PopupCloseBehavior::CloseOnClick);
let inside = inside_point(&p);
let out = p.on_mouse_down(inside, VIEWPORT);
assert!(out.inside && out.closed && out.consumed);
assert!(!p.is_open());
let mut p = popup(PopupCloseBehavior::CloseOnClick);
let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
assert!(!out.inside && out.closed && out.consumed);
assert!(!p.is_open());
}
#[test]
fn close_on_click_outside_keeps_open_when_inside() {
let mut p = popup(PopupCloseBehavior::CloseOnClickOutside);
let inside = inside_point(&p);
let out = p.on_mouse_down(inside, VIEWPORT);
assert!(out.inside && !out.closed && out.consumed);
assert!(p.is_open(), "click inside keeps a CloseOnClickOutside popup open");
let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
assert!(!out.inside && out.closed && out.consumed);
assert!(!p.is_open());
}
#[test]
fn ignore_clicks_never_closes_but_swallows_inside_clicks() {
let mut p = popup(PopupCloseBehavior::IgnoreClicks);
let inside = inside_point(&p);
let out = p.on_mouse_down(inside, VIEWPORT);
assert!(out.inside && !out.closed && out.consumed);
assert!(p.is_open());
let out = p.on_mouse_down(Point::new(5.0, 5.0), VIEWPORT);
assert!(!out.inside && !out.closed && !out.consumed);
assert!(p.is_open());
}
#[test]
fn closed_popup_ignores_clicks() {
let mut p = popup(PopupCloseBehavior::CloseOnClick);
p.close();
let out = p.on_mouse_down(Point::new(120.0, 190.0), VIEWPORT);
assert_eq!(out, PopupClickOutcome::default());
}
#[test]
fn behavior_table_indices_round_trip() {
for (i, (b, _, _)) in PopupCloseBehavior::ALL.iter().enumerate() {
assert_eq!(b.all_index(), i);
}
}
#[test]
fn default_close_behavior_matches_egui() {
assert_eq!(PopupCloseBehavior::default(), PopupCloseBehavior::CloseOnClick);
assert_eq!(Popup::default().close_behavior, PopupCloseBehavior::CloseOnClick);
}
#[test]
fn escape_closes_even_ignore_clicks_popups() {
let mut p = popup(PopupCloseBehavior::IgnoreClicks);
assert!(p.is_open());
assert!(p.on_escape(), "escape must close an IgnoreClicks popup");
assert!(!p.is_open());
assert!(!p.on_escape(), "second escape is a no-op");
}
#[test]
fn rect_flips_to_opposite_side_when_anchor_hugs_an_edge() {
let mut p = popup(PopupCloseBehavior::CloseOnClick);
p.set_anchor(Rect::new(100.0, 10.0, 40.0, 20.0));
assert_eq!(p.effective_align(VIEWPORT), RectAlign::TOP_START);
let r = p.rect(VIEWPORT);
assert_eq!(r.y, 10.0 + 20.0 + p.gap, "popup bottom sits gap above the anchor top");
p.set_anchor(Rect::new(100.0, 300.0, 40.0, 20.0));
assert_eq!(p.effective_align(VIEWPORT), RectAlign::BOTTOM_START);
}
}