Skip to main content

agg_gui/widgets/popup/
align.rs

1//! Anchor-relative placement math for popups — this framework's answer to
2//! egui's `RectAlign`.
3//!
4//! A popup attaches to an *anchor* rectangle (usually the bounds of the widget
5//! that opened it). [`RectAlign`] names a point on that anchor (`parent`) and a
6//! point on the popup (`child`); [`RectAlign::place_child`] positions the popup
7//! so its `child` point coincides with the anchor's `parent` point, then pushes
8//! it `gap` pixels outward.
9//!
10//! ## Y-up note
11//!
12//! Unlike egui (Y-down), this framework is first-quadrant / **Y-up**: `y` grows
13//! upward and the origin is the bottom-left. Consequently `Align::Max` on the Y
14//! axis is the **top** edge and `Align::Min` is the **bottom** edge. The named
15//! presets below (`TOP`, `BOTTOM`, …) are expressed so they land on the same
16//! *visual* side a user expects, not the numeric side egui's constants use.
17//!
18//! This module is the one genuinely new primitive the Popup API adds on top of
19//! the existing menu system (`super::super::menu`), which can only anchor a
20//! popup left-aligned and extending straight up or down. The placement is a
21//! pure function so it is unit-tested directly against production code.
22
23use crate::geometry::{Rect, Size};
24
25/// Viewport margin kept clear when clamping a popup on-screen. Matches the
26/// menu system's `MARGIN` so popups and menus hug the viewport edge the same.
27const MARGIN: f64 = 4.0;
28
29/// One-axis alignment: the fraction of an extent an anchor sits at.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Align {
32    /// Start of the axis (0.0). On Y this is the **bottom** edge (Y-up).
33    Min,
34    /// Middle of the axis (0.5).
35    Center,
36    /// End of the axis (1.0). On Y this is the **top** edge (Y-up).
37    Max,
38}
39
40impl Align {
41    /// Fraction of the extent this alignment sits at: 0.0 / 0.5 / 1.0.
42    pub fn frac(self) -> f64 {
43        match self {
44            Align::Min => 0.0,
45            Align::Center => 0.5,
46            Align::Max => 1.0,
47        }
48    }
49
50    /// Outward sign relative to the center: -1 at `Min`, 0 at `Center`,
51    /// +1 at `Max`. Used to push the popup away from the anchor by the gap.
52    pub fn sign(self) -> f64 {
53        match self {
54            Align::Min => -1.0,
55            Align::Center => 0.0,
56            Align::Max => 1.0,
57        }
58    }
59
60    /// Mirror this alignment: `Min` ↔ `Max`, `Center` stays.
61    /// Matches egui's `Align::flip` (emath/src/align.rs).
62    pub fn flip(self) -> Self {
63        match self {
64            Align::Min => Align::Max,
65            Align::Center => Align::Center,
66            Align::Max => Align::Min,
67        }
68    }
69}
70
71/// A 2D anchor point within a rectangle (an `x` and a `y` [`Align`]).
72///
73/// Mirrors egui's `Align2`. Remember the Y-up convention: `y: Max` is the top
74/// edge, `y: Min` the bottom.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub struct Align2 {
77    pub x: Align,
78    pub y: Align,
79}
80
81impl Align2 {
82    pub const LEFT_TOP: Self = Self {
83        x: Align::Min,
84        y: Align::Max,
85    };
86    pub const CENTER_TOP: Self = Self {
87        x: Align::Center,
88        y: Align::Max,
89    };
90    pub const RIGHT_TOP: Self = Self {
91        x: Align::Max,
92        y: Align::Max,
93    };
94    pub const LEFT_CENTER: Self = Self {
95        x: Align::Min,
96        y: Align::Center,
97    };
98    pub const CENTER: Self = Self {
99        x: Align::Center,
100        y: Align::Center,
101    };
102    pub const RIGHT_CENTER: Self = Self {
103        x: Align::Max,
104        y: Align::Center,
105    };
106    pub const LEFT_BOTTOM: Self = Self {
107        x: Align::Min,
108        y: Align::Min,
109    };
110    pub const CENTER_BOTTOM: Self = Self {
111        x: Align::Center,
112        y: Align::Min,
113    };
114    pub const RIGHT_BOTTOM: Self = Self {
115        x: Align::Max,
116        y: Align::Min,
117    };
118
119    /// All nine anchors with the display names egui's Popups demo uses, in
120    /// the same order its align combo box lists them (left column, center
121    /// column, right column). The array index is stable so UI (e.g. a combo
122    /// box) can map a selected index straight back to an `Align2`.
123    pub const ALL: [(Self, &'static str); 9] = [
124        (Self::LEFT_TOP, "LEFT_TOP"),
125        (Self::LEFT_CENTER, "LEFT_CENTER"),
126        (Self::LEFT_BOTTOM, "LEFT_BOTTOM"),
127        (Self::CENTER_TOP, "CENTER_TOP"),
128        (Self::CENTER, "CENTER_CENTER"),
129        (Self::CENTER_BOTTOM, "CENTER_BOTTOM"),
130        (Self::RIGHT_TOP, "RIGHT_TOP"),
131        (Self::RIGHT_CENTER, "RIGHT_CENTER"),
132        (Self::RIGHT_BOTTOM, "RIGHT_BOTTOM"),
133    ];
134
135    /// Index of this anchor within [`Align2::ALL`], or 0 if somehow absent.
136    pub fn all_index(self) -> usize {
137        Self::ALL
138            .iter()
139            .position(|(a, _)| *a == self)
140            .unwrap_or(0)
141    }
142
143    /// The absolute point this anchor names inside `rect`.
144    pub fn point_in(self, rect: Rect) -> (f64, f64) {
145        (
146            rect.x + rect.width * self.x.frac(),
147            rect.y + rect.height * self.y.frac(),
148        )
149    }
150
151    /// Mirror on the x-axis (e.g. `LEFT_TOP` → `RIGHT_TOP`).
152    pub fn flip_x(self) -> Self {
153        Self {
154            x: self.x.flip(),
155            y: self.y,
156        }
157    }
158
159    /// Mirror on the y-axis (e.g. `LEFT_TOP` → `LEFT_BOTTOM`).
160    pub fn flip_y(self) -> Self {
161        Self {
162            x: self.x,
163            y: self.y.flip(),
164        }
165    }
166
167    /// Mirror on both axes (e.g. `LEFT_TOP` → `RIGHT_BOTTOM`).
168    pub fn flip(self) -> Self {
169        Self {
170            x: self.x.flip(),
171            y: self.y.flip(),
172        }
173    }
174}
175
176/// A parent/child anchor pair describing how a popup attaches to its anchoring
177/// widget — the placement half of the Popup API (egui's `RectAlign`).
178#[derive(Clone, Copy, Debug, PartialEq, Eq)]
179pub struct RectAlign {
180    /// Anchor point on the parent (the widget the popup hangs off).
181    pub parent: Align2,
182    /// Anchor point on the popup itself.
183    pub child: Align2,
184}
185
186impl RectAlign {
187    /// Below the widget, left edges aligned.
188    pub const BOTTOM_START: Self = Self {
189        parent: Align2::LEFT_BOTTOM,
190        child: Align2::LEFT_TOP,
191    };
192    /// Below the widget, horizontally centered.
193    pub const BOTTOM: Self = Self {
194        parent: Align2::CENTER_BOTTOM,
195        child: Align2::CENTER_TOP,
196    };
197    /// Below the widget, right edges aligned.
198    pub const BOTTOM_END: Self = Self {
199        parent: Align2::RIGHT_BOTTOM,
200        child: Align2::RIGHT_TOP,
201    };
202    /// Above the widget, left edges aligned.
203    pub const TOP_START: Self = Self {
204        parent: Align2::LEFT_TOP,
205        child: Align2::LEFT_BOTTOM,
206    };
207    /// Above the widget, horizontally centered.
208    pub const TOP: Self = Self {
209        parent: Align2::CENTER_TOP,
210        child: Align2::CENTER_BOTTOM,
211    };
212    /// Above the widget, right edges aligned.
213    pub const TOP_END: Self = Self {
214        parent: Align2::RIGHT_TOP,
215        child: Align2::RIGHT_BOTTOM,
216    };
217    /// To the right of the widget, top edges aligned.
218    pub const RIGHT_START: Self = Self {
219        parent: Align2::RIGHT_TOP,
220        child: Align2::LEFT_TOP,
221    };
222    /// To the right of the widget, vertically centered.
223    pub const RIGHT: Self = Self {
224        parent: Align2::RIGHT_CENTER,
225        child: Align2::LEFT_CENTER,
226    };
227    /// To the right of the widget, bottom edges aligned.
228    pub const RIGHT_END: Self = Self {
229        parent: Align2::RIGHT_BOTTOM,
230        child: Align2::LEFT_BOTTOM,
231    };
232    /// To the left of the widget, top edges aligned.
233    pub const LEFT_START: Self = Self {
234        parent: Align2::LEFT_TOP,
235        child: Align2::RIGHT_TOP,
236    };
237    /// To the left of the widget, vertically centered.
238    pub const LEFT: Self = Self {
239        parent: Align2::LEFT_CENTER,
240        child: Align2::RIGHT_CENTER,
241    };
242    /// To the left of the widget, bottom edges aligned.
243    pub const LEFT_END: Self = Self {
244        parent: Align2::LEFT_BOTTOM,
245        child: Align2::RIGHT_BOTTOM,
246    };
247
248    /// The named presets with the display names and ordering of egui's Popups
249    /// demo preset combo box (egui_demo_lib/src/demo/popups.rs).
250    pub const PRESETS: [(Self, &'static str); 12] = [
251        (Self::TOP_START, "TOP_START"),
252        (Self::TOP, "TOP"),
253        (Self::TOP_END, "TOP_END"),
254        (Self::RIGHT_START, "RIGHT_START"),
255        (Self::RIGHT, "RIGHT"),
256        (Self::RIGHT_END, "RIGHT_END"),
257        (Self::BOTTOM_START, "BOTTOM_START"),
258        (Self::BOTTOM, "BOTTOM"),
259        (Self::BOTTOM_END, "BOTTOM_END"),
260        (Self::LEFT_START, "LEFT_START"),
261        (Self::LEFT, "LEFT"),
262        (Self::LEFT_END, "LEFT_END"),
263    ];
264
265    /// The 12 common menu positions in egui's fallback-preference order
266    /// (emath's `RectAlign::MENU_ALIGNS`): corner placements first, centered
267    /// ones last, for use with [`RectAlign::find_best_align`].
268    pub const MENU_ALIGNS: [Self; 12] = [
269        Self::BOTTOM_START,
270        Self::BOTTOM_END,
271        Self::TOP_START,
272        Self::TOP_END,
273        Self::RIGHT_END,
274        Self::RIGHT_START,
275        Self::LEFT_END,
276        Self::LEFT_START,
277        // These come last on purpose, we prefer the corner ones.
278        Self::TOP,
279        Self::RIGHT,
280        Self::BOTTOM,
281        Self::LEFT,
282    ];
283
284    /// The preset label matching this alignment exactly, or `None` for an
285    /// arbitrary (combo-box-composed) pair that isn't a named preset.
286    pub fn preset_label(self) -> Option<&'static str> {
287        Self::PRESETS
288            .iter()
289            .find(|(a, _)| *a == self)
290            .map(|(_, label)| *label)
291    }
292
293    /// Mirror the placement on the x-axis (e.g. `RIGHT` → `LEFT`).
294    pub fn flip_x(self) -> Self {
295        Self {
296            parent: self.parent.flip_x(),
297            child: self.child.flip_x(),
298        }
299    }
300
301    /// Mirror the placement on the y-axis (e.g. `BOTTOM_START` → `TOP_START`).
302    pub fn flip_y(self) -> Self {
303        Self {
304            parent: self.parent.flip_y(),
305            child: self.child.flip_y(),
306        }
307    }
308
309    /// Mirror the placement on both axes.
310    pub fn flip(self) -> Self {
311        Self {
312            parent: self.parent.flip(),
313            child: self.child.flip(),
314        }
315    }
316
317    /// The 3 alternative placements flipped in various ways, for use with
318    /// [`RectAlign::find_best_align`] (mirrors egui's `RectAlign::symmetries`).
319    pub fn symmetries(self) -> [Self; 3] {
320        [self.flip_x(), self.flip_y(), self.flip()]
321    }
322
323    /// Look for the first candidate placement whose popup rect fits entirely
324    /// inside `content_rect` — egui's overflow-flip (`find_best_align`).
325    ///
326    /// If no candidate fits, the FIRST candidate is returned (so the caller's
327    /// preferred alignment wins and downstream clamping takes over). Returns
328    /// `None` only when the iterator is empty.
329    pub fn find_best_align(
330        values_to_try: impl Iterator<Item = Self>,
331        content_rect: Rect,
332        parent_rect: Rect,
333        gap: f64,
334        expected_size: Size,
335    ) -> Option<Self> {
336        let mut first_choice = None;
337        for align in values_to_try {
338            first_choice = first_choice.or(Some(align));
339            let suggested = align.place_child(parent_rect, expected_size, gap);
340            if contains_rect(content_rect, suggested) {
341                return Some(align);
342            }
343        }
344        first_choice
345    }
346
347    /// Place a popup of `size` relative to `parent`, applying `gap` pixels of
348    /// separation.
349    ///
350    /// The gap is applied only along axes where the parent and child anchors
351    /// *differ* — i.e. the side the popup extends toward — so an edge-attached
352    /// popup (e.g. [`RectAlign::BOTTOM_START`]) gets vertical separation without
353    /// being nudged sideways.
354    pub fn place_child(self, parent: Rect, size: Size, gap: f64) -> Rect {
355        let (px, py) = self.parent.point_in(parent);
356        let gx = if self.parent.x != self.child.x {
357            self.parent.x.sign()
358        } else {
359            0.0
360        };
361        let gy = if self.parent.y != self.child.y {
362            self.parent.y.sign()
363        } else {
364            0.0
365        };
366        let ax = px + gap * gx;
367        let ay = py + gap * gy;
368        let cx = ax - size.width * self.child.x.frac();
369        let cy = ay - size.height * self.child.y.frac();
370        Rect::new(cx, cy, size.width, size.height)
371    }
372
373    /// [`place_child`](Self::place_child) then clamp the result fully inside
374    /// `viewport`, leaving a small margin — so a popup near an edge stays
375    /// on-screen, matching how the menu system clamps its panels.
376    pub fn place_child_clamped(self, parent: Rect, size: Size, gap: f64, viewport: Size) -> Rect {
377        clamp_rect(self.place_child(parent, size, gap), viewport)
378    }
379}
380
381/// `true` when `inner` lies entirely within `outer` (edges may touch).
382fn contains_rect(outer: Rect, inner: Rect) -> bool {
383    inner.x >= outer.x
384        && inner.y >= outer.y
385        && inner.x + inner.width <= outer.x + outer.width
386        && inner.y + inner.height <= outer.y + outer.height
387}
388
389/// Clamp `rect` inside `viewport` with a [`MARGIN`] gutter. If the rect is
390/// larger than the viewport it is pinned to the low edge rather than pushed
391/// off the high one.
392pub fn clamp_rect(rect: Rect, viewport: Size) -> Rect {
393    let max_x = (viewport.width - rect.width - MARGIN).max(MARGIN);
394    let max_y = (viewport.height - rect.height - MARGIN).max(MARGIN);
395    Rect::new(
396        rect.x.clamp(MARGIN, max_x),
397        rect.y.clamp(MARGIN, max_y),
398        rect.width,
399        rect.height,
400    )
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    const PARENT: Rect = Rect {
408        x: 100.0,
409        y: 100.0,
410        width: 40.0,
411        height: 20.0,
412    };
413    const SIZE: Size = Size {
414        width: 60.0,
415        height: 30.0,
416    };
417    const GAP: f64 = 4.0;
418
419    #[test]
420    fn bottom_start_hangs_below_left_aligned() {
421        // Y-up: "below" means the popup sits at SMALLER y, its top edge one
422        // gap under the parent's bottom edge, left edges flush.
423        let r = RectAlign::BOTTOM_START.place_child(PARENT, SIZE, GAP);
424        assert_eq!(r, Rect::new(100.0, 66.0, 60.0, 30.0));
425        // Left edges aligned.
426        assert_eq!(r.x, PARENT.x);
427        // Top of popup is exactly `gap` below the parent's bottom.
428        assert_eq!(r.y + r.height, PARENT.y - GAP);
429    }
430
431    #[test]
432    fn top_is_centered_above() {
433        let r = RectAlign::TOP.place_child(PARENT, SIZE, GAP);
434        // Bottom of popup one gap above parent top.
435        assert_eq!(r.y, PARENT.y + PARENT.height + GAP);
436        // Horizontal centers coincide.
437        let popup_center_x = r.x + r.width * 0.5;
438        let parent_center_x = PARENT.x + PARENT.width * 0.5;
439        assert!((popup_center_x - parent_center_x).abs() < 1e-9);
440    }
441
442    #[test]
443    fn right_is_centered_to_the_right() {
444        let r = RectAlign::RIGHT.place_child(PARENT, SIZE, GAP);
445        // Left edge of popup one gap right of parent's right edge.
446        assert_eq!(r.x, PARENT.x + PARENT.width + GAP);
447        // Vertical centers coincide.
448        let popup_center_y = r.y + r.height * 0.5;
449        let parent_center_y = PARENT.y + PARENT.height * 0.5;
450        assert!((popup_center_y - parent_center_y).abs() < 1e-9);
451    }
452
453    #[test]
454    fn left_is_centered_to_the_left() {
455        let r = RectAlign::LEFT.place_child(PARENT, SIZE, GAP);
456        // Right edge of popup one gap left of parent's left edge.
457        assert_eq!(r.x + r.width, PARENT.x - GAP);
458        let popup_center_y = r.y + r.height * 0.5;
459        let parent_center_y = PARENT.y + PARENT.height * 0.5;
460        assert!((popup_center_y - parent_center_y).abs() < 1e-9);
461    }
462
463    #[test]
464    fn zero_gap_puts_child_anchor_on_parent_anchor() {
465        // With no gap the child's anchor point should sit exactly on the
466        // parent's anchor point for an arbitrary pair.
467        let align = RectAlign {
468            parent: Align2::RIGHT_TOP,
469            child: Align2::LEFT_BOTTOM,
470        };
471        let r = align.place_child(PARENT, SIZE, 0.0);
472        let (px, py) = Align2::RIGHT_TOP.point_in(PARENT);
473        let (cx, cy) = Align2::LEFT_BOTTOM.point_in(r);
474        assert!((px - cx).abs() < 1e-9);
475        assert!((py - cy).abs() < 1e-9);
476    }
477
478    #[test]
479    fn clamp_keeps_popup_on_screen() {
480        // A bottom-start popup off the left/bottom edge is clamped back in.
481        let parent = Rect::new(2.0, 6.0, 40.0, 20.0);
482        let viewport = Size::new(400.0, 300.0);
483        let r = RectAlign::BOTTOM_START.place_child_clamped(parent, SIZE, GAP, viewport);
484        assert!(r.x >= 4.0);
485        assert!(r.y >= 4.0);
486        assert!(r.x + r.width <= viewport.width);
487        assert!(r.y + r.height <= viewport.height);
488    }
489
490    #[test]
491    fn presets_and_anchor_tables_round_trip() {
492        // Every preset is discoverable by label, and every Align2 maps back to
493        // its own index — the demo relies on both.
494        assert_eq!(RectAlign::BOTTOM.preset_label(), Some("BOTTOM"));
495        for (i, (a, _)) in Align2::ALL.iter().enumerate() {
496            assert_eq!(a.all_index(), i);
497        }
498    }
499
500    #[test]
501    fn flips_mirror_the_named_presets() {
502        // Same pairings egui documents on Align2::flip_x/flip_y/flip.
503        assert_eq!(RectAlign::BOTTOM_START.flip_y(), RectAlign::TOP_START);
504        assert_eq!(RectAlign::TOP_END.flip_y(), RectAlign::BOTTOM_END);
505        assert_eq!(RectAlign::LEFT.flip_x(), RectAlign::RIGHT);
506        assert_eq!(RectAlign::RIGHT_START.flip_x(), RectAlign::LEFT_START);
507        assert_eq!(RectAlign::BOTTOM_START.flip(), RectAlign::TOP_END);
508        // symmetries() = [flip_x, flip_y, flip], in that order.
509        assert_eq!(
510            RectAlign::BOTTOM_START.symmetries(),
511            [
512                RectAlign::BOTTOM_END,
513                RectAlign::TOP_START,
514                RectAlign::TOP_END
515            ]
516        );
517    }
518
519    /// Candidate order egui's `Popup::get_best_align` tries: the configured
520    /// align, then its symmetries, then the full MENU_ALIGNS table.
521    fn candidates(align: RectAlign) -> impl Iterator<Item = RectAlign> {
522        std::iter::once(align)
523            .chain(align.symmetries())
524            .chain(RectAlign::MENU_ALIGNS)
525    }
526
527    #[test]
528    fn find_best_align_flips_at_each_screen_edge() {
529        let screen = Rect::new(0.0, 0.0, 400.0, 300.0);
530        let size = Size::new(100.0, 80.0);
531
532        // Anchor near the BOTTOM edge (Y-up: small y): BOTTOM_START overflows
533        // downward, flip_y (TOP_START) survives.
534        let parent = Rect::new(150.0, 10.0, 40.0, 20.0);
535        let best = RectAlign::find_best_align(
536            candidates(RectAlign::BOTTOM_START),
537            screen,
538            parent,
539            GAP,
540            size,
541        );
542        assert_eq!(best, Some(RectAlign::TOP_START));
543
544        // Anchor near the TOP edge: TOP_START overflows upward, flips back down.
545        let parent = Rect::new(150.0, 270.0, 40.0, 20.0);
546        let best = RectAlign::find_best_align(
547            candidates(RectAlign::TOP_START),
548            screen,
549            parent,
550            GAP,
551            size,
552        );
553        assert_eq!(best, Some(RectAlign::BOTTOM_START));
554
555        // Anchor near the RIGHT edge: RIGHT_START overflows, LEFT_START survives.
556        let parent = Rect::new(340.0, 100.0, 40.0, 20.0);
557        let best = RectAlign::find_best_align(
558            candidates(RectAlign::RIGHT_START),
559            screen,
560            parent,
561            GAP,
562            size,
563        );
564        assert_eq!(best, Some(RectAlign::LEFT_START));
565
566        // Anchor near the LEFT edge: LEFT_START overflows, RIGHT_START survives.
567        let parent = Rect::new(10.0, 100.0, 40.0, 20.0);
568        let best = RectAlign::find_best_align(
569            candidates(RectAlign::LEFT_START),
570            screen,
571            parent,
572            GAP,
573            size,
574        );
575        assert_eq!(best, Some(RectAlign::RIGHT_START));
576    }
577
578    #[test]
579    fn find_best_align_keeps_preferred_align_when_it_fits() {
580        let screen = Rect::new(0.0, 0.0, 400.0, 300.0);
581        let parent = Rect::new(150.0, 150.0, 40.0, 20.0);
582        let best = RectAlign::find_best_align(
583            candidates(RectAlign::BOTTOM_START),
584            screen,
585            parent,
586            GAP,
587            Size::new(100.0, 80.0),
588        );
589        assert_eq!(best, Some(RectAlign::BOTTOM_START));
590    }
591
592    #[test]
593    fn find_best_align_falls_back_to_first_candidate_when_nothing_fits() {
594        // Popup bigger than the screen: nothing fits, first candidate returned
595        // (the caller's preferred align), None only for an empty iterator.
596        let screen = Rect::new(0.0, 0.0, 100.0, 100.0);
597        let parent = Rect::new(40.0, 40.0, 20.0, 10.0);
598        let big = Size::new(500.0, 500.0);
599        let best = RectAlign::find_best_align(
600            candidates(RectAlign::BOTTOM_START),
601            screen,
602            parent,
603            GAP,
604            big,
605        );
606        assert_eq!(best, Some(RectAlign::BOTTOM_START));
607        assert_eq!(
608            RectAlign::find_best_align(std::iter::empty(), screen, parent, GAP, big),
609            None
610        );
611    }
612}