1use crate::core::{Point, Rectangle, Size};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum Position {
7 Auto {
10 preference: Side,
12 },
13 Top,
15 Bottom,
17 Left,
19 Right,
21 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 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 Position::FollowCursor => {
57 Point::new(cursor_position.x, cursor_position.y - popup.height)
58 }
59 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
99pub enum Side {
100 Top,
102 Left,
104 Right,
106 #[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 if preference.available_space(content_bounds, viewport, popup, gap) >= 0.0 {
121 return preference;
122 }
123
124 [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 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 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 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 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 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 let base = Rectangle::new(Point::new(950.0, 0.0), Size::new(50.0, 50.0));
298
299 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}