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.iter().position(|(a, _)| *a == self).unwrap_or(0)
138    }
139
140    /// The absolute point this anchor names inside `rect`.
141    pub fn point_in(self, rect: Rect) -> (f64, f64) {
142        (
143            rect.x + rect.width * self.x.frac(),
144            rect.y + rect.height * self.y.frac(),
145        )
146    }
147
148    /// Mirror on the x-axis (e.g. `LEFT_TOP` → `RIGHT_TOP`).
149    pub fn flip_x(self) -> Self {
150        Self {
151            x: self.x.flip(),
152            y: self.y,
153        }
154    }
155
156    /// Mirror on the y-axis (e.g. `LEFT_TOP` → `LEFT_BOTTOM`).
157    pub fn flip_y(self) -> Self {
158        Self {
159            x: self.x,
160            y: self.y.flip(),
161        }
162    }
163
164    /// Mirror on both axes (e.g. `LEFT_TOP` → `RIGHT_BOTTOM`).
165    pub fn flip(self) -> Self {
166        Self {
167            x: self.x.flip(),
168            y: self.y.flip(),
169        }
170    }
171}
172
173/// A parent/child anchor pair describing how a popup attaches to its anchoring
174/// widget — the placement half of the Popup API (egui's `RectAlign`).
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub struct RectAlign {
177    /// Anchor point on the parent (the widget the popup hangs off).
178    pub parent: Align2,
179    /// Anchor point on the popup itself.
180    pub child: Align2,
181}
182
183impl RectAlign {
184    /// Below the widget, left edges aligned.
185    pub const BOTTOM_START: Self = Self {
186        parent: Align2::LEFT_BOTTOM,
187        child: Align2::LEFT_TOP,
188    };
189    /// Below the widget, horizontally centered.
190    pub const BOTTOM: Self = Self {
191        parent: Align2::CENTER_BOTTOM,
192        child: Align2::CENTER_TOP,
193    };
194    /// Below the widget, right edges aligned.
195    pub const BOTTOM_END: Self = Self {
196        parent: Align2::RIGHT_BOTTOM,
197        child: Align2::RIGHT_TOP,
198    };
199    /// Above the widget, left edges aligned.
200    pub const TOP_START: Self = Self {
201        parent: Align2::LEFT_TOP,
202        child: Align2::LEFT_BOTTOM,
203    };
204    /// Above the widget, horizontally centered.
205    pub const TOP: Self = Self {
206        parent: Align2::CENTER_TOP,
207        child: Align2::CENTER_BOTTOM,
208    };
209    /// Above the widget, right edges aligned.
210    pub const TOP_END: Self = Self {
211        parent: Align2::RIGHT_TOP,
212        child: Align2::RIGHT_BOTTOM,
213    };
214    /// To the right of the widget, top edges aligned.
215    pub const RIGHT_START: Self = Self {
216        parent: Align2::RIGHT_TOP,
217        child: Align2::LEFT_TOP,
218    };
219    /// To the right of the widget, vertically centered.
220    pub const RIGHT: Self = Self {
221        parent: Align2::RIGHT_CENTER,
222        child: Align2::LEFT_CENTER,
223    };
224    /// To the right of the widget, bottom edges aligned.
225    pub const RIGHT_END: Self = Self {
226        parent: Align2::RIGHT_BOTTOM,
227        child: Align2::LEFT_BOTTOM,
228    };
229    /// To the left of the widget, top edges aligned.
230    pub const LEFT_START: Self = Self {
231        parent: Align2::LEFT_TOP,
232        child: Align2::RIGHT_TOP,
233    };
234    /// To the left of the widget, vertically centered.
235    pub const LEFT: Self = Self {
236        parent: Align2::LEFT_CENTER,
237        child: Align2::RIGHT_CENTER,
238    };
239    /// To the left of the widget, bottom edges aligned.
240    pub const LEFT_END: Self = Self {
241        parent: Align2::LEFT_BOTTOM,
242        child: Align2::RIGHT_BOTTOM,
243    };
244
245    /// The named presets with the display names and ordering of egui's Popups
246    /// demo preset combo box (egui_demo_lib/src/demo/popups.rs).
247    pub const PRESETS: [(Self, &'static str); 12] = [
248        (Self::TOP_START, "TOP_START"),
249        (Self::TOP, "TOP"),
250        (Self::TOP_END, "TOP_END"),
251        (Self::RIGHT_START, "RIGHT_START"),
252        (Self::RIGHT, "RIGHT"),
253        (Self::RIGHT_END, "RIGHT_END"),
254        (Self::BOTTOM_START, "BOTTOM_START"),
255        (Self::BOTTOM, "BOTTOM"),
256        (Self::BOTTOM_END, "BOTTOM_END"),
257        (Self::LEFT_START, "LEFT_START"),
258        (Self::LEFT, "LEFT"),
259        (Self::LEFT_END, "LEFT_END"),
260    ];
261
262    /// The 12 common menu positions in egui's fallback-preference order
263    /// (emath's `RectAlign::MENU_ALIGNS`): corner placements first, centered
264    /// ones last, for use with [`RectAlign::find_best_align`].
265    pub const MENU_ALIGNS: [Self; 12] = [
266        Self::BOTTOM_START,
267        Self::BOTTOM_END,
268        Self::TOP_START,
269        Self::TOP_END,
270        Self::RIGHT_END,
271        Self::RIGHT_START,
272        Self::LEFT_END,
273        Self::LEFT_START,
274        // These come last on purpose, we prefer the corner ones.
275        Self::TOP,
276        Self::RIGHT,
277        Self::BOTTOM,
278        Self::LEFT,
279    ];
280
281    /// The preset label matching this alignment exactly, or `None` for an
282    /// arbitrary (combo-box-composed) pair that isn't a named preset.
283    pub fn preset_label(self) -> Option<&'static str> {
284        Self::PRESETS
285            .iter()
286            .find(|(a, _)| *a == self)
287            .map(|(_, label)| *label)
288    }
289
290    /// Mirror the placement on the x-axis (e.g. `RIGHT` → `LEFT`).
291    pub fn flip_x(self) -> Self {
292        Self {
293            parent: self.parent.flip_x(),
294            child: self.child.flip_x(),
295        }
296    }
297
298    /// Mirror the placement on the y-axis (e.g. `BOTTOM_START` → `TOP_START`).
299    pub fn flip_y(self) -> Self {
300        Self {
301            parent: self.parent.flip_y(),
302            child: self.child.flip_y(),
303        }
304    }
305
306    /// Mirror the placement on both axes.
307    pub fn flip(self) -> Self {
308        Self {
309            parent: self.parent.flip(),
310            child: self.child.flip(),
311        }
312    }
313
314    /// The 3 alternative placements flipped in various ways, for use with
315    /// [`RectAlign::find_best_align`] (mirrors egui's `RectAlign::symmetries`).
316    pub fn symmetries(self) -> [Self; 3] {
317        [self.flip_x(), self.flip_y(), self.flip()]
318    }
319
320    /// Look for the first candidate placement whose popup rect fits entirely
321    /// inside `content_rect` — egui's overflow-flip (`find_best_align`).
322    ///
323    /// If no candidate fits, the FIRST candidate is returned (so the caller's
324    /// preferred alignment wins and downstream clamping takes over). Returns
325    /// `None` only when the iterator is empty.
326    pub fn find_best_align(
327        values_to_try: impl Iterator<Item = Self>,
328        content_rect: Rect,
329        parent_rect: Rect,
330        gap: f64,
331        expected_size: Size,
332    ) -> Option<Self> {
333        let mut first_choice = None;
334        for align in values_to_try {
335            first_choice = first_choice.or(Some(align));
336            let suggested = align.place_child(parent_rect, expected_size, gap);
337            if contains_rect(content_rect, suggested) {
338                return Some(align);
339            }
340        }
341        first_choice
342    }
343
344    /// Place a popup of `size` relative to `parent`, applying `gap` pixels of
345    /// separation.
346    ///
347    /// The gap is applied only along axes where the parent and child anchors
348    /// *differ* — i.e. the side the popup extends toward — so an edge-attached
349    /// popup (e.g. [`RectAlign::BOTTOM_START`]) gets vertical separation without
350    /// being nudged sideways.
351    pub fn place_child(self, parent: Rect, size: Size, gap: f64) -> Rect {
352        let (px, py) = self.parent.point_in(parent);
353        let gx = if self.parent.x != self.child.x {
354            self.parent.x.sign()
355        } else {
356            0.0
357        };
358        let gy = if self.parent.y != self.child.y {
359            self.parent.y.sign()
360        } else {
361            0.0
362        };
363        let ax = px + gap * gx;
364        let ay = py + gap * gy;
365        let cx = ax - size.width * self.child.x.frac();
366        let cy = ay - size.height * self.child.y.frac();
367        Rect::new(cx, cy, size.width, size.height)
368    }
369
370    /// [`place_child`](Self::place_child) then clamp the result fully inside
371    /// `viewport`, leaving a small margin — so a popup near an edge stays
372    /// on-screen, matching how the menu system clamps its panels.
373    pub fn place_child_clamped(self, parent: Rect, size: Size, gap: f64, viewport: Size) -> Rect {
374        clamp_rect(self.place_child(parent, size, gap), viewport)
375    }
376}
377
378/// `true` when `inner` lies entirely within `outer` (edges may touch).
379fn contains_rect(outer: Rect, inner: Rect) -> bool {
380    inner.x >= outer.x
381        && inner.y >= outer.y
382        && inner.x + inner.width <= outer.x + outer.width
383        && inner.y + inner.height <= outer.y + outer.height
384}
385
386/// Clamp `rect` inside `viewport` with a [`MARGIN`] gutter. If the rect is
387/// larger than the viewport it is pinned to the low edge rather than pushed
388/// off the high one.
389pub fn clamp_rect(rect: Rect, viewport: Size) -> Rect {
390    let max_x = (viewport.width - rect.width - MARGIN).max(MARGIN);
391    let max_y = (viewport.height - rect.height - MARGIN).max(MARGIN);
392    Rect::new(
393        rect.x.clamp(MARGIN, max_x),
394        rect.y.clamp(MARGIN, max_y),
395        rect.width,
396        rect.height,
397    )
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    const PARENT: Rect = Rect {
405        x: 100.0,
406        y: 100.0,
407        width: 40.0,
408        height: 20.0,
409    };
410    const SIZE: Size = Size {
411        width: 60.0,
412        height: 30.0,
413    };
414    const GAP: f64 = 4.0;
415
416    #[test]
417    fn bottom_start_hangs_below_left_aligned() {
418        // Y-up: "below" means the popup sits at SMALLER y, its top edge one
419        // gap under the parent's bottom edge, left edges flush.
420        let r = RectAlign::BOTTOM_START.place_child(PARENT, SIZE, GAP);
421        assert_eq!(r, Rect::new(100.0, 66.0, 60.0, 30.0));
422        // Left edges aligned.
423        assert_eq!(r.x, PARENT.x);
424        // Top of popup is exactly `gap` below the parent's bottom.
425        assert_eq!(r.y + r.height, PARENT.y - GAP);
426    }
427
428    #[test]
429    fn top_is_centered_above() {
430        let r = RectAlign::TOP.place_child(PARENT, SIZE, GAP);
431        // Bottom of popup one gap above parent top.
432        assert_eq!(r.y, PARENT.y + PARENT.height + GAP);
433        // Horizontal centers coincide.
434        let popup_center_x = r.x + r.width * 0.5;
435        let parent_center_x = PARENT.x + PARENT.width * 0.5;
436        assert!((popup_center_x - parent_center_x).abs() < 1e-9);
437    }
438
439    #[test]
440    fn right_is_centered_to_the_right() {
441        let r = RectAlign::RIGHT.place_child(PARENT, SIZE, GAP);
442        // Left edge of popup one gap right of parent's right edge.
443        assert_eq!(r.x, PARENT.x + PARENT.width + GAP);
444        // Vertical centers coincide.
445        let popup_center_y = r.y + r.height * 0.5;
446        let parent_center_y = PARENT.y + PARENT.height * 0.5;
447        assert!((popup_center_y - parent_center_y).abs() < 1e-9);
448    }
449
450    #[test]
451    fn left_is_centered_to_the_left() {
452        let r = RectAlign::LEFT.place_child(PARENT, SIZE, GAP);
453        // Right edge of popup one gap left of parent's left edge.
454        assert_eq!(r.x + r.width, PARENT.x - GAP);
455        let popup_center_y = r.y + r.height * 0.5;
456        let parent_center_y = PARENT.y + PARENT.height * 0.5;
457        assert!((popup_center_y - parent_center_y).abs() < 1e-9);
458    }
459
460    #[test]
461    fn zero_gap_puts_child_anchor_on_parent_anchor() {
462        // With no gap the child's anchor point should sit exactly on the
463        // parent's anchor point for an arbitrary pair.
464        let align = RectAlign {
465            parent: Align2::RIGHT_TOP,
466            child: Align2::LEFT_BOTTOM,
467        };
468        let r = align.place_child(PARENT, SIZE, 0.0);
469        let (px, py) = Align2::RIGHT_TOP.point_in(PARENT);
470        let (cx, cy) = Align2::LEFT_BOTTOM.point_in(r);
471        assert!((px - cx).abs() < 1e-9);
472        assert!((py - cy).abs() < 1e-9);
473    }
474
475    #[test]
476    fn clamp_keeps_popup_on_screen() {
477        // A bottom-start popup off the left/bottom edge is clamped back in.
478        let parent = Rect::new(2.0, 6.0, 40.0, 20.0);
479        let viewport = Size::new(400.0, 300.0);
480        let r = RectAlign::BOTTOM_START.place_child_clamped(parent, SIZE, GAP, viewport);
481        assert!(r.x >= 4.0);
482        assert!(r.y >= 4.0);
483        assert!(r.x + r.width <= viewport.width);
484        assert!(r.y + r.height <= viewport.height);
485    }
486
487    #[test]
488    fn presets_and_anchor_tables_round_trip() {
489        // Every preset is discoverable by label, and every Align2 maps back to
490        // its own index — the demo relies on both.
491        assert_eq!(RectAlign::BOTTOM.preset_label(), Some("BOTTOM"));
492        for (i, (a, _)) in Align2::ALL.iter().enumerate() {
493            assert_eq!(a.all_index(), i);
494        }
495    }
496
497    #[test]
498    fn flips_mirror_the_named_presets() {
499        // Same pairings egui documents on Align2::flip_x/flip_y/flip.
500        assert_eq!(RectAlign::BOTTOM_START.flip_y(), RectAlign::TOP_START);
501        assert_eq!(RectAlign::TOP_END.flip_y(), RectAlign::BOTTOM_END);
502        assert_eq!(RectAlign::LEFT.flip_x(), RectAlign::RIGHT);
503        assert_eq!(RectAlign::RIGHT_START.flip_x(), RectAlign::LEFT_START);
504        assert_eq!(RectAlign::BOTTOM_START.flip(), RectAlign::TOP_END);
505        // symmetries() = [flip_x, flip_y, flip], in that order.
506        assert_eq!(
507            RectAlign::BOTTOM_START.symmetries(),
508            [
509                RectAlign::BOTTOM_END,
510                RectAlign::TOP_START,
511                RectAlign::TOP_END
512            ]
513        );
514    }
515
516    /// Candidate order egui's `Popup::get_best_align` tries: the configured
517    /// align, then its symmetries, then the full MENU_ALIGNS table.
518    fn candidates(align: RectAlign) -> impl Iterator<Item = RectAlign> {
519        std::iter::once(align)
520            .chain(align.symmetries())
521            .chain(RectAlign::MENU_ALIGNS)
522    }
523
524    #[test]
525    fn find_best_align_flips_at_each_screen_edge() {
526        let screen = Rect::new(0.0, 0.0, 400.0, 300.0);
527        let size = Size::new(100.0, 80.0);
528
529        // Anchor near the BOTTOM edge (Y-up: small y): BOTTOM_START overflows
530        // downward, flip_y (TOP_START) survives.
531        let parent = Rect::new(150.0, 10.0, 40.0, 20.0);
532        let best = RectAlign::find_best_align(
533            candidates(RectAlign::BOTTOM_START),
534            screen,
535            parent,
536            GAP,
537            size,
538        );
539        assert_eq!(best, Some(RectAlign::TOP_START));
540
541        // Anchor near the TOP edge: TOP_START overflows upward, flips back down.
542        let parent = Rect::new(150.0, 270.0, 40.0, 20.0);
543        let best =
544            RectAlign::find_best_align(candidates(RectAlign::TOP_START), screen, parent, GAP, size);
545        assert_eq!(best, Some(RectAlign::BOTTOM_START));
546
547        // Anchor near the RIGHT edge: RIGHT_START overflows, LEFT_START survives.
548        let parent = Rect::new(340.0, 100.0, 40.0, 20.0);
549        let best = RectAlign::find_best_align(
550            candidates(RectAlign::RIGHT_START),
551            screen,
552            parent,
553            GAP,
554            size,
555        );
556        assert_eq!(best, Some(RectAlign::LEFT_START));
557
558        // Anchor near the LEFT edge: LEFT_START overflows, RIGHT_START survives.
559        let parent = Rect::new(10.0, 100.0, 40.0, 20.0);
560        let best = RectAlign::find_best_align(
561            candidates(RectAlign::LEFT_START),
562            screen,
563            parent,
564            GAP,
565            size,
566        );
567        assert_eq!(best, Some(RectAlign::RIGHT_START));
568    }
569
570    #[test]
571    fn find_best_align_keeps_preferred_align_when_it_fits() {
572        let screen = Rect::new(0.0, 0.0, 400.0, 300.0);
573        let parent = Rect::new(150.0, 150.0, 40.0, 20.0);
574        let best = RectAlign::find_best_align(
575            candidates(RectAlign::BOTTOM_START),
576            screen,
577            parent,
578            GAP,
579            Size::new(100.0, 80.0),
580        );
581        assert_eq!(best, Some(RectAlign::BOTTOM_START));
582    }
583
584    #[test]
585    fn find_best_align_falls_back_to_first_candidate_when_nothing_fits() {
586        // Popup bigger than the screen: nothing fits, first candidate returned
587        // (the caller's preferred align), None only for an empty iterator.
588        let screen = Rect::new(0.0, 0.0, 100.0, 100.0);
589        let parent = Rect::new(40.0, 40.0, 20.0, 10.0);
590        let big = Size::new(500.0, 500.0);
591        let best = RectAlign::find_best_align(
592            candidates(RectAlign::BOTTOM_START),
593            screen,
594            parent,
595            GAP,
596            big,
597        );
598        assert_eq!(best, Some(RectAlign::BOTTOM_START));
599        assert_eq!(
600            RectAlign::find_best_align(std::iter::empty(), screen, parent, GAP, big),
601            None
602        );
603    }
604}