Skip to main content

dioxus_bootstrap_css/
overlay.rs

1/// Overlay placement relative to a trigger element.
2#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3pub enum OverlayPlacement {
4    /// Choose the first fitting fallback placement.
5    Auto,
6    /// Place overlay above the trigger.
7    #[default]
8    Top,
9    /// Place overlay below the trigger.
10    Bottom,
11    /// Place overlay before the trigger in the inline axis.
12    Start,
13    /// Place overlay after the trigger in the inline axis.
14    End,
15}
16
17impl OverlayPlacement {
18    /// Default fallback order matching Bootstrap's Popper-backed overlays.
19    pub const DEFAULT_FALLBACKS: [OverlayPlacement; 4] = [
20        OverlayPlacement::Top,
21        OverlayPlacement::End,
22        OverlayPlacement::Bottom,
23        OverlayPlacement::Start,
24    ];
25}
26
27/// Rectangle in viewport coordinates.
28#[derive(Clone, Copy, Debug, Default, PartialEq)]
29pub struct OverlayRect {
30    pub x: f64,
31    pub y: f64,
32    pub width: f64,
33    pub height: f64,
34}
35
36impl OverlayRect {
37    pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self {
38        Self {
39            x,
40            y,
41            width,
42            height,
43        }
44    }
45
46    pub fn right(self) -> f64 {
47        self.x + self.width
48    }
49
50    pub fn bottom(self) -> f64 {
51        self.y + self.height
52    }
53
54    pub fn center_x(self) -> f64 {
55        self.x + self.width / 2.0
56    }
57
58    pub fn center_y(self) -> f64 {
59        self.y + self.height / 2.0
60    }
61}
62
63/// Overlay offset from the trigger.
64#[derive(Clone, Copy, Debug, Default, PartialEq)]
65pub struct OverlayOffset {
66    /// Cross-axis offset.
67    pub skidding: f64,
68    /// Main-axis distance from the trigger.
69    pub distance: f64,
70}
71
72impl OverlayOffset {
73    pub const ZERO: Self = Self {
74        skidding: 0.0,
75        distance: 0.0,
76    };
77
78    /// Bootstrap tooltip default offset.
79    pub const TOOLTIP: Self = Self {
80        skidding: 0.0,
81        distance: 6.0,
82    };
83
84    /// Bootstrap popover default offset.
85    pub const POPOVER: Self = Self {
86        skidding: 0.0,
87        distance: 8.0,
88    };
89}
90
91/// Calculated overlay position.
92#[derive(Clone, Copy, Debug, PartialEq)]
93pub struct OverlayPosition {
94    pub x: f64,
95    pub y: f64,
96    pub placement: OverlayPlacement,
97    /// True when the overlay fits inside the boundary without clamping.
98    pub fits: bool,
99}
100
101impl OverlayPosition {
102    pub fn rect(self, overlay_size: OverlayRect) -> OverlayRect {
103        OverlayRect::new(self.x, self.y, overlay_size.width, overlay_size.height)
104    }
105}
106
107/// Calculate a viewport-aware overlay position.
108///
109/// `overlay_size.x` and `overlay_size.y` are ignored; only width and height are
110/// used. If no candidate fully fits, the placement with the largest visible
111/// area is selected and clamped inside the padded boundary as far as possible.
112pub fn calculate_overlay_position(
113    trigger: OverlayRect,
114    overlay_size: OverlayRect,
115    boundary: OverlayRect,
116    requested: OverlayPlacement,
117    fallback_placements: &[OverlayPlacement],
118    offset: OverlayOffset,
119    boundary_padding: f64,
120) -> OverlayPosition {
121    let candidates = candidate_placements(requested, fallback_placements);
122    let mut best: Option<(OverlayPlacement, OverlayRect, f64)> = None;
123
124    for placement in candidates {
125        let rect = placed_rect(trigger, overlay_size, placement, offset);
126        if fits_boundary(rect, boundary, boundary_padding) {
127            return OverlayPosition {
128                x: rect.x,
129                y: rect.y,
130                placement,
131                fits: true,
132            };
133        }
134
135        let visible = visible_area(rect, boundary, boundary_padding);
136        if best
137            .map(|(_, _, best_visible)| visible > best_visible)
138            .unwrap_or(true)
139        {
140            best = Some((placement, rect, visible));
141        }
142    }
143
144    let (placement, rect, _) = best.unwrap_or_else(|| {
145        let placement = OverlayPlacement::Top;
146        (
147            placement,
148            placed_rect(trigger, overlay_size, placement, offset),
149            0.0,
150        )
151    });
152    let clamped = clamp_to_boundary(rect, boundary, boundary_padding);
153
154    OverlayPosition {
155        x: clamped.x,
156        y: clamped.y,
157        placement,
158        fits: false,
159    }
160}
161
162fn candidate_placements(
163    requested: OverlayPlacement,
164    fallback_placements: &[OverlayPlacement],
165) -> Vec<OverlayPlacement> {
166    let mut candidates = Vec::new();
167
168    if requested == OverlayPlacement::Auto {
169        push_candidates(&mut candidates, fallback_placements);
170        if candidates.is_empty() {
171            push_candidates(&mut candidates, &OverlayPlacement::DEFAULT_FALLBACKS);
172        }
173    } else {
174        candidates.push(requested);
175        push_candidates(&mut candidates, fallback_placements);
176    }
177
178    candidates
179}
180
181fn push_candidates(candidates: &mut Vec<OverlayPlacement>, placements: &[OverlayPlacement]) {
182    for placement in placements {
183        if *placement != OverlayPlacement::Auto && !candidates.contains(placement) {
184            candidates.push(*placement);
185        }
186    }
187}
188
189fn placed_rect(
190    trigger: OverlayRect,
191    overlay_size: OverlayRect,
192    placement: OverlayPlacement,
193    offset: OverlayOffset,
194) -> OverlayRect {
195    match placement {
196        OverlayPlacement::Auto => placed_rect(trigger, overlay_size, OverlayPlacement::Top, offset),
197        OverlayPlacement::Top => OverlayRect::new(
198            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
199            trigger.y - overlay_size.height - offset.distance,
200            overlay_size.width,
201            overlay_size.height,
202        ),
203        OverlayPlacement::Bottom => OverlayRect::new(
204            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
205            trigger.bottom() + offset.distance,
206            overlay_size.width,
207            overlay_size.height,
208        ),
209        OverlayPlacement::Start => OverlayRect::new(
210            trigger.x - overlay_size.width - offset.distance,
211            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
212            overlay_size.width,
213            overlay_size.height,
214        ),
215        OverlayPlacement::End => OverlayRect::new(
216            trigger.right() + offset.distance,
217            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
218            overlay_size.width,
219            overlay_size.height,
220        ),
221    }
222}
223
224fn fits_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> bool {
225    rect.x >= boundary.x + padding
226        && rect.y >= boundary.y + padding
227        && rect.right() <= boundary.right() - padding
228        && rect.bottom() <= boundary.bottom() - padding
229}
230
231fn visible_area(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> f64 {
232    let min_x = boundary.x + padding;
233    let min_y = boundary.y + padding;
234    let max_x = boundary.right() - padding;
235    let max_y = boundary.bottom() - padding;
236
237    let width = (rect.right().min(max_x) - rect.x.max(min_x)).max(0.0);
238    let height = (rect.bottom().min(max_y) - rect.y.max(min_y)).max(0.0);
239    width * height
240}
241
242fn clamp_to_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> OverlayRect {
243    let min_x = boundary.x + padding;
244    let min_y = boundary.y + padding;
245    let max_x = (boundary.right() - padding - rect.width).max(min_x);
246    let max_y = (boundary.bottom() - padding - rect.height).max(min_y);
247
248    OverlayRect::new(
249        rect.x.clamp(min_x, max_x),
250        rect.y.clamp(min_y, max_y),
251        rect.width,
252        rect.height,
253    )
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn trigger() -> OverlayRect {
261        OverlayRect::new(100.0, 100.0, 40.0, 20.0)
262    }
263
264    fn overlay() -> OverlayRect {
265        OverlayRect::new(0.0, 0.0, 80.0, 30.0)
266    }
267
268    fn boundary() -> OverlayRect {
269        OverlayRect::new(0.0, 0.0, 300.0, 300.0)
270    }
271
272    #[test]
273    fn requested_top_fits() {
274        let position = calculate_overlay_position(
275            trigger(),
276            overlay(),
277            boundary(),
278            OverlayPlacement::Top,
279            &OverlayPlacement::DEFAULT_FALLBACKS,
280            OverlayOffset::TOOLTIP,
281            0.0,
282        );
283
284        assert_eq!(position.placement, OverlayPlacement::Top);
285        assert!(position.fits);
286        assert_eq!(position.x, 80.0);
287        assert_eq!(position.y, 64.0);
288    }
289
290    #[test]
291    fn offset_skids_on_cross_axis() {
292        let position = calculate_overlay_position(
293            trigger(),
294            overlay(),
295            boundary(),
296            OverlayPlacement::Bottom,
297            &[],
298            OverlayOffset {
299                skidding: 10.0,
300                distance: 12.0,
301            },
302            0.0,
303        );
304
305        assert_eq!(position.placement, OverlayPlacement::Bottom);
306        assert!(position.fits);
307        assert_eq!(position.x, 90.0);
308        assert_eq!(position.y, 132.0);
309    }
310
311    #[test]
312    fn falls_back_when_requested_placement_overflows() {
313        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
314        let position = calculate_overlay_position(
315            edge_trigger,
316            overlay(),
317            boundary(),
318            OverlayPlacement::Top,
319            &[OverlayPlacement::Bottom, OverlayPlacement::End],
320            OverlayOffset::TOOLTIP,
321            0.0,
322        );
323
324        assert_eq!(position.placement, OverlayPlacement::Bottom);
325        assert!(position.fits);
326        assert_eq!(position.y, 36.0);
327    }
328
329    #[test]
330    fn auto_uses_first_fitting_fallback() {
331        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
332        let position = calculate_overlay_position(
333            edge_trigger,
334            overlay(),
335            boundary(),
336            OverlayPlacement::Auto,
337            &[
338                OverlayPlacement::Top,
339                OverlayPlacement::Bottom,
340                OverlayPlacement::End,
341            ],
342            OverlayOffset::TOOLTIP,
343            0.0,
344        );
345
346        assert_eq!(position.placement, OverlayPlacement::Bottom);
347        assert!(position.fits);
348    }
349
350    #[test]
351    fn start_and_end_place_on_inline_axis() {
352        let start = calculate_overlay_position(
353            trigger(),
354            overlay(),
355            boundary(),
356            OverlayPlacement::Start,
357            &[],
358            OverlayOffset::POPOVER,
359            0.0,
360        );
361        let end = calculate_overlay_position(
362            trigger(),
363            overlay(),
364            boundary(),
365            OverlayPlacement::End,
366            &[],
367            OverlayOffset::POPOVER,
368            0.0,
369        );
370
371        assert_eq!(start.x, 12.0);
372        assert_eq!(start.y, 95.0);
373        assert_eq!(end.x, 148.0);
374        assert_eq!(end.y, 95.0);
375    }
376
377    #[test]
378    fn clamps_best_candidate_to_boundary_padding() {
379        let edge_trigger = OverlayRect::new(0.0, 120.0, 20.0, 20.0);
380        let position = calculate_overlay_position(
381            edge_trigger,
382            overlay(),
383            boundary(),
384            OverlayPlacement::Top,
385            &[],
386            OverlayOffset::ZERO,
387            8.0,
388        );
389
390        assert_eq!(position.placement, OverlayPlacement::Top);
391        assert!(!position.fits);
392        assert_eq!(position.x, 8.0);
393        assert_eq!(position.y, 90.0);
394    }
395
396    #[test]
397    fn clamps_oversized_overlay_to_boundary_start() {
398        let oversized = OverlayRect::new(0.0, 0.0, 400.0, 400.0);
399        let position = calculate_overlay_position(
400            trigger(),
401            oversized,
402            boundary(),
403            OverlayPlacement::Bottom,
404            &[],
405            OverlayOffset::ZERO,
406            8.0,
407        );
408
409        assert!(!position.fits);
410        assert_eq!(position.x, 8.0);
411        assert_eq!(position.y, 8.0);
412    }
413}