Skip to main content

iced_widget/overlay/
position.rs

1use crate::core::{Point, Rectangle, Size};
2
3/// The position of a popup (a popover or a tooltip) relative to the element
4/// it is anchored to.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum Position {
7    /// The popup will appear on the preferred [`Side`] if it fits there, and
8    /// on the side of the widget with the most available space otherwise.
9    Auto {
10        /// The [`Side`] to prefer when the popup fits there.
11        preference: Side,
12    },
13    /// The popup will appear on the top of the widget.
14    Top,
15    /// The popup will appear on the bottom of the widget.
16    Bottom,
17    /// The popup will appear on the left of the widget.
18    Left,
19    /// The popup will appear on the right of the widget.
20    Right,
21    /// The popup will follow the cursor.
22    FollowCursor,
23}
24
25impl Default for Position {
26    fn default() -> Self {
27        Position::Auto {
28            preference: Side::default(),
29        }
30    }
31}
32
33impl Position {
34    /// Computes the [`Rectangle`] that a popup occupies for a [`Position`].
35    ///
36    /// All the arguments are expressed in the same coordinate space: the
37    /// space in which the popup is laid out. The `content_bounds` describe
38    /// the element the popup is anchored to, `popup` is the size of the
39    /// popup itself, and `gap` is the amount of space to leave around it.
40    ///
41    /// The `cursor_position` is only used when the [`Position`] is
42    /// [`Position::FollowCursor`], and the `viewport` is only used when it is
43    /// [`Position::Auto`] or when `snap_within_viewport` is `true`, which
44    /// clamps the resolved rectangle into the `viewport`.
45    pub fn resolve(
46        &self,
47        content_bounds: Rectangle,
48        popup: Size,
49        gap: f32,
50        cursor_position: Point,
51        viewport: Rectangle,
52        snap_within_viewport: bool,
53    ) -> Rectangle {
54        let offset = match self {
55            // The popup follows the cursor.
56            Position::FollowCursor => {
57                Point::new(cursor_position.x, cursor_position.y - popup.height)
58            }
59            // The popup is placed on a side of the base; `Auto` resolves to
60            // the preferred side if the popup fits there, and to the side
61            // with the most available space otherwise.
62            Position::Auto { preference } => {
63                Side::with_most_available_space(content_bounds, viewport, popup, gap, *preference)
64                    .offset(content_bounds, popup, gap)
65            }
66            Position::Top => Side::Top.offset(content_bounds, popup, gap),
67            Position::Bottom => Side::Bottom.offset(content_bounds, popup, gap),
68            Position::Left => Side::Left.offset(content_bounds, popup, gap),
69            Position::Right => Side::Right.offset(content_bounds, popup, gap),
70        };
71
72        let mut rectangle = Rectangle {
73            x: offset.x,
74            y: offset.y,
75            width: popup.width,
76            height: popup.height,
77        };
78
79        if snap_within_viewport {
80            if rectangle.x < viewport.x {
81                rectangle.x = viewport.x;
82            } else if viewport.x + viewport.width < rectangle.x + rectangle.width {
83                rectangle.x = viewport.x + viewport.width - rectangle.width;
84            }
85
86            if rectangle.y < viewport.y {
87                rectangle.y = viewport.y;
88            } else if viewport.y + viewport.height < rectangle.y + rectangle.height {
89                rectangle.y = viewport.y + viewport.height - rectangle.height;
90            }
91        }
92
93        rectangle
94    }
95}
96
97/// A side that a popup can appear on.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum Side {
100    /// The top side.
101    Top,
102    /// The left side.
103    Left,
104    /// The right side.
105    Right,
106    /// The bottom side.
107    #[default]
108    Bottom,
109}
110
111impl Side {
112    fn with_most_available_space(
113        content_bounds: Rectangle,
114        viewport: Rectangle,
115        popup: Size,
116        gap: f32,
117        preference: Side,
118    ) -> Side {
119        // The popup is placed on the preferred side if it fits there.
120        if preference.available_space(content_bounds, viewport, popup, gap) >= 0.0 {
121            return preference;
122        }
123
124        // Otherwise, it is placed on the side with the most available space.
125        [Side::Top, Side::Left, Side::Right, Side::Bottom]
126            .into_iter()
127            .max_by(|a, b| {
128                a.available_space(content_bounds, viewport, popup, gap)
129                    .total_cmp(&b.available_space(content_bounds, viewport, popup, gap))
130            })
131            .unwrap_or_default()
132    }
133
134    fn available_space(
135        self,
136        content_bounds: Rectangle,
137        viewport: Rectangle,
138        popup: Size,
139        gap: f32,
140    ) -> f32 {
141        match self {
142            Side::Top => content_bounds.y - viewport.y - popup.height - gap,
143            Side::Bottom => {
144                (viewport.y + viewport.height)
145                    - (content_bounds.y + content_bounds.height)
146                    - popup.height
147                    - gap
148            }
149            Side::Left => content_bounds.x - viewport.x - popup.width - gap,
150            Side::Right => {
151                (viewport.x + viewport.width)
152                    - (content_bounds.x + content_bounds.width)
153                    - popup.width
154                    - gap
155            }
156        }
157    }
158
159    fn offset(self, content_bounds: Rectangle, popup: Size, gap: f32) -> Point {
160        let x_center = content_bounds.x + (content_bounds.width - popup.width) / 2.0;
161        let y_center = content_bounds.y + (content_bounds.height - popup.height) / 2.0;
162
163        match self {
164            Side::Top => Point::new(x_center, content_bounds.y - popup.height - gap),
165            Side::Bottom => Point::new(x_center, content_bounds.y + content_bounds.height + gap),
166            Side::Left => Point::new(content_bounds.x - popup.width - gap, y_center),
167            Side::Right => Point::new(content_bounds.x + content_bounds.width + gap, y_center),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::{Position, Side};
175    use crate::core::{Point, Rectangle, Size};
176
177    /// Common inputs: a 50x50 base at the origin, an 80x80 popup, no gap, a
178    /// cursor at the origin, and a 1000x1000 viewport.
179    const BASE: Rectangle = Rectangle::new(Point::new(0.0, 0.0), Size::new(50.0, 50.0));
180    const POPUP: Size = Size::new(80.0, 80.0);
181    const GAP: f32 = 0.0;
182    const CURSOR: Point = Point::new(0.0, 0.0);
183    const VIEWPORT: Rectangle = Rectangle::new(Point::new(0.0, 0.0), Size::new(1000.0, 1000.0));
184
185    #[test]
186    fn auto_uses_the_preferred_side_when_it_fits() {
187        // There is more space above (520) than below (270); the preference
188        // still wins because the popup fits below.
189        let base = Rectangle::new(Point::new(0.0, 600.0), Size::new(50.0, 50.0));
190        let position = Position::Auto {
191            preference: Side::Bottom,
192        };
193
194        let rect = position.resolve(base, POPUP, GAP, CURSOR, VIEWPORT, false);
195
196        assert_eq!(
197            rect,
198            Rectangle::new(Point::new(-15.0, 650.0), Size::new(80.0, 80.0)),
199            "Auto should use the preferred side when the popup fits there"
200        );
201    }
202
203    #[test]
204    fn auto_falls_back_to_the_side_with_the_most_space() {
205        // The preferred side (`Bottom`) does not fit: the popup (960) is
206        // taller than the space below the base (950), so the popup is placed
207        // on the side with the most available space (right) instead.
208        let popup = Size::new(80.0, 960.0);
209        let position = Position::Auto {
210            preference: Side::Bottom,
211        };
212
213        let rect = position.resolve(BASE, popup, GAP, CURSOR, VIEWPORT, false);
214
215        assert_eq!(
216            rect,
217            Rectangle::new(Point::new(50.0, -455.0), Size::new(80.0, 960.0)),
218            "Auto should fall back to the side with the most available space"
219        );
220    }
221
222    #[test]
223    fn top_places_the_popup_above_the_base() {
224        let rect = Position::Top.resolve(BASE, POPUP, GAP, CURSOR, VIEWPORT, false);
225
226        assert_eq!(
227            rect,
228            Rectangle::new(Point::new(-15.0, -80.0), Size::new(80.0, 80.0)),
229            "Top should place the popup above the base"
230        );
231    }
232
233    #[test]
234    fn bottom_places_the_popup_below_the_base() {
235        let rect = Position::Bottom.resolve(BASE, POPUP, GAP, CURSOR, VIEWPORT, false);
236
237        assert_eq!(
238            rect,
239            Rectangle::new(Point::new(-15.0, 50.0), Size::new(80.0, 80.0)),
240            "Bottom should place the popup below the base"
241        );
242    }
243
244    #[test]
245    fn left_places_the_popup_left_of_the_base() {
246        let rect = Position::Left.resolve(BASE, POPUP, GAP, CURSOR, VIEWPORT, false);
247
248        assert_eq!(
249            rect,
250            Rectangle::new(Point::new(-80.0, -15.0), Size::new(80.0, 80.0)),
251            "Left should place the popup to the left of the base"
252        );
253    }
254
255    #[test]
256    fn right_places_the_popup_right_of_the_base() {
257        let rect = Position::Right.resolve(BASE, POPUP, GAP, CURSOR, VIEWPORT, false);
258
259        assert_eq!(
260            rect,
261            Rectangle::new(Point::new(50.0, -15.0), Size::new(80.0, 80.0)),
262            "Right should place the popup to the right of the base"
263        );
264    }
265
266    #[test]
267    fn follow_cursor_places_the_popup_at_the_cursor() {
268        let cursor = Point::new(200.0, 300.0);
269
270        // The popup's bottom edge is placed at the cursor.
271        let rect = Position::FollowCursor.resolve(BASE, POPUP, GAP, cursor, VIEWPORT, false);
272
273        assert_eq!(
274            rect,
275            Rectangle::new(Point::new(200.0, 220.0), Size::new(80.0, 80.0)),
276            "FollowCursor should place the popup at the cursor"
277        );
278    }
279
280    #[test]
281    fn snap_clamps_the_popup_into_the_viewport() {
282        // Without snapping, `Top` places the popup at (-15, -80), outside of
283        // the viewport; with snapping, it is clamped to the viewport's
284        // origin.
285        let rect = Position::Top.resolve(BASE, POPUP, GAP, CURSOR, VIEWPORT, true);
286
287        assert_eq!(
288            rect,
289            Rectangle::new(Point::new(0.0, 0.0), Size::new(80.0, 80.0)),
290            "Snap should clamp the popup into the viewport"
291        );
292    }
293
294    #[test]
295    fn snap_shifts_a_popup_that_overflows_the_viewport() {
296        // A base flush with the right edge of the viewport (1000).
297        let base = Rectangle::new(Point::new(950.0, 0.0), Size::new(50.0, 50.0));
298
299        // Without snapping, `Right` places the popup at (1000, -15), beyond
300        // the right edge of the viewport; with snapping, it is shifted back
301        // so it fits.
302        let rect = Position::Right.resolve(base, POPUP, GAP, CURSOR, VIEWPORT, true);
303
304        assert_eq!(
305            rect,
306            Rectangle::new(Point::new(920.0, 0.0), Size::new(80.0, 80.0)),
307            "Snap should shift a popup that overflows the viewport"
308        );
309    }
310}