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/// Bootstrap arrow width (`--bs-popover-arrow-width` / tooltip equivalent, `1rem`).
92const ARROW_SIZE: f64 = 16.0;
93/// Keep the arrow this far from the overlay's rounded corner so it never straddles
94/// the border radius.
95const ARROW_EDGE_INSET: f64 = 8.0;
96
97/// Calculated overlay position.
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct OverlayPosition {
100    pub x: f64,
101    pub y: f64,
102    pub placement: OverlayPlacement,
103    /// True when the overlay fits inside the boundary without clamping.
104    pub fits: bool,
105    /// Arrow centre in overlay-local coordinates along the cross axis (x for
106    /// top/bottom placements, y for start/end). Lets the caller keep the arrow
107    /// pointing at the trigger even after the overlay box is clamped to the
108    /// viewport — the job Popper.js does for Bootstrap's own overlays.
109    pub arrow: f64,
110}
111
112impl OverlayPosition {
113    pub fn rect(self, overlay_size: OverlayRect) -> OverlayRect {
114        OverlayRect::new(self.x, self.y, overlay_size.width, overlay_size.height)
115    }
116}
117
118/// Arrow centre (cross-axis, overlay-local) that keeps the arrow over the trigger
119/// centre, clamped so it stays clear of the overlay's rounded corners.
120fn arrow_offset(trigger: OverlayRect, rect: OverlayRect, placement: OverlayPlacement) -> f64 {
121    let (target, cross_size) = match placement {
122        OverlayPlacement::Start | OverlayPlacement::End => {
123            (trigger.center_y() - rect.y, rect.height)
124        }
125        // Top / Bottom / Auto place on the vertical axis, so the arrow slides in x.
126        _ => (trigger.center_x() - rect.x, rect.width),
127    };
128    let lo = ARROW_EDGE_INSET + ARROW_SIZE / 2.0;
129    let hi = (cross_size - ARROW_EDGE_INSET - ARROW_SIZE / 2.0).max(lo);
130    target.clamp(lo, hi)
131}
132
133/// Calculate a viewport-aware overlay position.
134///
135/// `overlay_size.x` and `overlay_size.y` are ignored; only width and height are
136/// used. If no candidate fully fits, the placement with the largest visible
137/// area is selected and clamped inside the padded boundary as far as possible.
138pub fn calculate_overlay_position(
139    trigger: OverlayRect,
140    overlay_size: OverlayRect,
141    boundary: OverlayRect,
142    requested: OverlayPlacement,
143    fallback_placements: &[OverlayPlacement],
144    offset: OverlayOffset,
145    boundary_padding: f64,
146) -> OverlayPosition {
147    let candidates = candidate_placements(requested, fallback_placements);
148    let mut best: Option<(OverlayPlacement, OverlayRect, f64)> = None;
149
150    for placement in candidates {
151        let rect = placed_rect(trigger, overlay_size, placement, offset);
152        if fits_boundary(rect, boundary, boundary_padding) {
153            return OverlayPosition {
154                x: rect.x,
155                y: rect.y,
156                placement,
157                fits: true,
158                arrow: arrow_offset(trigger, rect, placement),
159            };
160        }
161
162        let visible = visible_area(rect, boundary, boundary_padding);
163        if best
164            .map(|(_, _, best_visible)| visible > best_visible)
165            .unwrap_or(true)
166        {
167            best = Some((placement, rect, visible));
168        }
169    }
170
171    let (placement, rect, _) = best.unwrap_or_else(|| {
172        let placement = OverlayPlacement::Top;
173        (
174            placement,
175            placed_rect(trigger, overlay_size, placement, offset),
176            0.0,
177        )
178    });
179    let clamped = clamp_to_boundary(rect, boundary, boundary_padding);
180
181    OverlayPosition {
182        x: clamped.x,
183        y: clamped.y,
184        placement,
185        fits: false,
186        arrow: arrow_offset(trigger, clamped, placement),
187    }
188}
189
190fn candidate_placements(
191    requested: OverlayPlacement,
192    fallback_placements: &[OverlayPlacement],
193) -> Vec<OverlayPlacement> {
194    let mut candidates = Vec::new();
195
196    if requested == OverlayPlacement::Auto {
197        push_candidates(&mut candidates, fallback_placements);
198        if candidates.is_empty() {
199            push_candidates(&mut candidates, &OverlayPlacement::DEFAULT_FALLBACKS);
200        }
201    } else {
202        candidates.push(requested);
203        push_candidates(&mut candidates, fallback_placements);
204    }
205
206    candidates
207}
208
209fn push_candidates(candidates: &mut Vec<OverlayPlacement>, placements: &[OverlayPlacement]) {
210    for placement in placements {
211        if *placement != OverlayPlacement::Auto && !candidates.contains(placement) {
212            candidates.push(*placement);
213        }
214    }
215}
216
217fn placed_rect(
218    trigger: OverlayRect,
219    overlay_size: OverlayRect,
220    placement: OverlayPlacement,
221    offset: OverlayOffset,
222) -> OverlayRect {
223    match placement {
224        OverlayPlacement::Auto => placed_rect(trigger, overlay_size, OverlayPlacement::Top, offset),
225        OverlayPlacement::Top => OverlayRect::new(
226            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
227            trigger.y - overlay_size.height - offset.distance,
228            overlay_size.width,
229            overlay_size.height,
230        ),
231        OverlayPlacement::Bottom => OverlayRect::new(
232            trigger.center_x() - overlay_size.width / 2.0 + offset.skidding,
233            trigger.bottom() + offset.distance,
234            overlay_size.width,
235            overlay_size.height,
236        ),
237        OverlayPlacement::Start => OverlayRect::new(
238            trigger.x - overlay_size.width - offset.distance,
239            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
240            overlay_size.width,
241            overlay_size.height,
242        ),
243        OverlayPlacement::End => OverlayRect::new(
244            trigger.right() + offset.distance,
245            trigger.center_y() - overlay_size.height / 2.0 + offset.skidding,
246            overlay_size.width,
247            overlay_size.height,
248        ),
249    }
250}
251
252fn fits_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> bool {
253    rect.x >= boundary.x + padding
254        && rect.y >= boundary.y + padding
255        && rect.right() <= boundary.right() - padding
256        && rect.bottom() <= boundary.bottom() - padding
257}
258
259fn visible_area(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> f64 {
260    let min_x = boundary.x + padding;
261    let min_y = boundary.y + padding;
262    let max_x = boundary.right() - padding;
263    let max_y = boundary.bottom() - padding;
264
265    let width = (rect.right().min(max_x) - rect.x.max(min_x)).max(0.0);
266    let height = (rect.bottom().min(max_y) - rect.y.max(min_y)).max(0.0);
267    width * height
268}
269
270fn clamp_to_boundary(rect: OverlayRect, boundary: OverlayRect, padding: f64) -> OverlayRect {
271    let min_x = boundary.x + padding;
272    let min_y = boundary.y + padding;
273    let max_x = (boundary.right() - padding - rect.width).max(min_x);
274    let max_y = (boundary.bottom() - padding - rect.height).max(min_y);
275
276    OverlayRect::new(
277        rect.x.clamp(min_x, max_x),
278        rect.y.clamp(min_y, max_y),
279        rect.width,
280        rect.height,
281    )
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    fn trigger() -> OverlayRect {
289        OverlayRect::new(100.0, 100.0, 40.0, 20.0)
290    }
291
292    fn overlay() -> OverlayRect {
293        OverlayRect::new(0.0, 0.0, 80.0, 30.0)
294    }
295
296    fn boundary() -> OverlayRect {
297        OverlayRect::new(0.0, 0.0, 300.0, 300.0)
298    }
299
300    #[test]
301    fn requested_top_fits() {
302        let position = calculate_overlay_position(
303            trigger(),
304            overlay(),
305            boundary(),
306            OverlayPlacement::Top,
307            &OverlayPlacement::DEFAULT_FALLBACKS,
308            OverlayOffset::TOOLTIP,
309            0.0,
310        );
311
312        assert_eq!(position.placement, OverlayPlacement::Top);
313        assert!(position.fits);
314        assert_eq!(position.x, 80.0);
315        assert_eq!(position.y, 64.0);
316    }
317
318    #[test]
319    fn offset_skids_on_cross_axis() {
320        let position = calculate_overlay_position(
321            trigger(),
322            overlay(),
323            boundary(),
324            OverlayPlacement::Bottom,
325            &[],
326            OverlayOffset {
327                skidding: 10.0,
328                distance: 12.0,
329            },
330            0.0,
331        );
332
333        assert_eq!(position.placement, OverlayPlacement::Bottom);
334        assert!(position.fits);
335        assert_eq!(position.x, 90.0);
336        assert_eq!(position.y, 132.0);
337    }
338
339    #[test]
340    fn falls_back_when_requested_placement_overflows() {
341        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
342        let position = calculate_overlay_position(
343            edge_trigger,
344            overlay(),
345            boundary(),
346            OverlayPlacement::Top,
347            &[OverlayPlacement::Bottom, OverlayPlacement::End],
348            OverlayOffset::TOOLTIP,
349            0.0,
350        );
351
352        assert_eq!(position.placement, OverlayPlacement::Bottom);
353        assert!(position.fits);
354        assert_eq!(position.y, 36.0);
355    }
356
357    #[test]
358    fn auto_uses_first_fitting_fallback() {
359        let edge_trigger = OverlayRect::new(100.0, 10.0, 40.0, 20.0);
360        let position = calculate_overlay_position(
361            edge_trigger,
362            overlay(),
363            boundary(),
364            OverlayPlacement::Auto,
365            &[
366                OverlayPlacement::Top,
367                OverlayPlacement::Bottom,
368                OverlayPlacement::End,
369            ],
370            OverlayOffset::TOOLTIP,
371            0.0,
372        );
373
374        assert_eq!(position.placement, OverlayPlacement::Bottom);
375        assert!(position.fits);
376    }
377
378    #[test]
379    fn start_and_end_place_on_inline_axis() {
380        let start = calculate_overlay_position(
381            trigger(),
382            overlay(),
383            boundary(),
384            OverlayPlacement::Start,
385            &[],
386            OverlayOffset::POPOVER,
387            0.0,
388        );
389        let end = calculate_overlay_position(
390            trigger(),
391            overlay(),
392            boundary(),
393            OverlayPlacement::End,
394            &[],
395            OverlayOffset::POPOVER,
396            0.0,
397        );
398
399        assert_eq!(start.x, 12.0);
400        assert_eq!(start.y, 95.0);
401        assert_eq!(end.x, 148.0);
402        assert_eq!(end.y, 95.0);
403    }
404
405    #[test]
406    fn clamps_best_candidate_to_boundary_padding() {
407        let edge_trigger = OverlayRect::new(0.0, 120.0, 20.0, 20.0);
408        let position = calculate_overlay_position(
409            edge_trigger,
410            overlay(),
411            boundary(),
412            OverlayPlacement::Top,
413            &[],
414            OverlayOffset::ZERO,
415            8.0,
416        );
417
418        assert_eq!(position.placement, OverlayPlacement::Top);
419        assert!(!position.fits);
420        assert_eq!(position.x, 8.0);
421        assert_eq!(position.y, 90.0);
422    }
423
424    #[test]
425    fn arrow_is_centred_when_overlay_fits() {
426        // Centred trigger, overlay fits: the arrow sits at the overlay's centre.
427        let position = calculate_overlay_position(
428            OverlayRect::new(130.0, 20.0, 40.0, 20.0), // center_x = 150
429            OverlayRect::new(0.0, 0.0, 80.0, 30.0),
430            boundary(),
431            OverlayPlacement::Bottom,
432            &[],
433            OverlayOffset::ZERO,
434            0.0,
435        );
436        assert!(position.fits);
437        assert_eq!(position.x, 110.0);
438        // 150 - 110 = 40 = overlay width / 2 (centred).
439        assert_eq!(position.arrow, 40.0);
440    }
441
442    #[test]
443    fn arrow_tracks_trigger_after_horizontal_clamp() {
444        // A trigger near the right edge: the bottom overlay is centred on it,
445        // overflows the boundary, and is clamped left. The arrow must keep pointing
446        // at the trigger centre, not drift to the (now shifted) overlay centre.
447        let position = calculate_overlay_position(
448            OverlayRect::new(280.0, 20.0, 20.0, 20.0), // center_x = 290
449            OverlayRect::new(0.0, 0.0, 120.0, 60.0),
450            boundary(), // 300 wide
451            OverlayPlacement::Bottom,
452            &[],
453            OverlayOffset::ZERO,
454            0.0,
455        );
456        assert_eq!(position.placement, OverlayPlacement::Bottom);
457        assert!(!position.fits);
458        assert_eq!(position.x, 180.0); // clamped so right edge hits 300
459        // trigger.center_x - x = 290 - 180 = 110, clamped to [16, 104] -> 104:
460        // the arrow hugs the trigger side, not the overlay centre (60).
461        assert_eq!(position.arrow, 104.0);
462    }
463
464    #[test]
465    fn clamps_oversized_overlay_to_boundary_start() {
466        let oversized = OverlayRect::new(0.0, 0.0, 400.0, 400.0);
467        let position = calculate_overlay_position(
468            trigger(),
469            oversized,
470            boundary(),
471            OverlayPlacement::Bottom,
472            &[],
473            OverlayOffset::ZERO,
474            8.0,
475        );
476
477        assert!(!position.fits);
478        assert_eq!(position.x, 8.0);
479        assert_eq!(position.y, 8.0);
480    }
481}