Skip to main content

eui_core/
overlay.rs

1//! The OVERLAY placement rule every renderer keeps (`src/overlay.ts` in the
2//! TS package; `fixtures/overlay-geometry.json` locks it): an anchor box, the
3//! overlay's size, the viewport and a preferred side → where it lands.
4//! `OVERLAY_OFFSET` px from the anchor on the preferred side, centred on the
5//! cross axis; FLIP when the preferred side has less room than needed and the
6//! opposite has more; then SHIFT on the cross axis to stay `OVERLAY_PADDING`
7//! inside the viewport.
8
9use serde::{Deserialize, Serialize};
10
11pub const OVERLAY_OFFSET: f64 = 4.0;
12pub const OVERLAY_PADDING: f64 = 8.0;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum OverlaySide {
17    Top,
18    Right,
19    Bottom,
20    Left,
21}
22
23impl OverlaySide {
24    pub fn parse(s: &str) -> Option<OverlaySide> {
25        Some(match s {
26            "top" => OverlaySide::Top,
27            "right" => OverlaySide::Right,
28            "bottom" => OverlaySide::Bottom,
29            "left" => OverlaySide::Left,
30            _ => return None,
31        })
32    }
33
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            OverlaySide::Top => "top",
37            OverlaySide::Right => "right",
38            OverlaySide::Bottom => "bottom",
39            OverlaySide::Left => "left",
40        }
41    }
42
43    fn opposite(self) -> OverlaySide {
44        match self {
45            OverlaySide::Top => OverlaySide::Bottom,
46            OverlaySide::Bottom => OverlaySide::Top,
47            OverlaySide::Left => OverlaySide::Right,
48            OverlaySide::Right => OverlaySide::Left,
49        }
50    }
51
52    fn vertical(self) -> bool {
53        matches!(self, OverlaySide::Top | OverlaySide::Bottom)
54    }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
58#[serde(rename_all = "lowercase")]
59pub enum OverlayAlign {
60    Start,
61    #[default]
62    Center,
63    End,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
67pub struct Rect {
68    pub x: f64,
69    pub y: f64,
70    pub width: f64,
71    pub height: f64,
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
75pub struct Size {
76    pub width: f64,
77    pub height: f64,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub struct OverlayPlacement {
82    pub x: f64,
83    pub y: f64,
84    pub side: OverlaySide,
85    /// True when the preferred side was swapped for its opposite.
86    pub flipped: bool,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq)]
90pub struct PlaceOptions {
91    pub side: OverlaySide,
92    pub align: OverlayAlign,
93    pub offset: f64,
94    pub padding: f64,
95}
96
97impl Default for PlaceOptions {
98    fn default() -> Self {
99        PlaceOptions { side: OverlaySide::Bottom, align: OverlayAlign::Center, offset: OVERLAY_OFFSET, padding: OVERLAY_PADDING }
100    }
101}
102
103impl PlaceOptions {
104    pub fn side(side: OverlaySide) -> Self {
105        PlaceOptions { side, ..Default::default() }
106    }
107}
108
109fn room(side: OverlaySide, anchor: &Rect, viewport: &Size, padding: f64) -> f64 {
110    match side {
111        OverlaySide::Top => anchor.y - padding,
112        OverlaySide::Bottom => viewport.height - (anchor.y + anchor.height) - padding,
113        OverlaySide::Left => anchor.x - padding,
114        OverlaySide::Right => viewport.width - (anchor.x + anchor.width) - padding,
115    }
116}
117
118fn round3(n: f64) -> f64 {
119    (n * 1000.0).round() / 1000.0
120}
121
122/// Mirrors `placeOverlay` exactly (the TS rounds to 3 decimals).
123pub fn place_overlay(anchor: &Rect, size: &Size, viewport: &Size, options: &PlaceOptions) -> OverlayPlacement {
124    let preferred = options.side;
125    let offset = options.offset;
126    let padding = options.padding;
127    let vertical = preferred.vertical();
128    let needed = if vertical { size.height } else { size.width } + offset;
129    let mut side = preferred;
130    let mut flipped = false;
131    let preferred_room = room(preferred, anchor, viewport, padding);
132    if preferred_room < needed && room(preferred.opposite(), anchor, viewport, padding) > preferred_room {
133        side = preferred.opposite();
134        flipped = true;
135    }
136    let along_x = || match options.align {
137        OverlayAlign::Start => anchor.x,
138        OverlayAlign::End => anchor.x + anchor.width - size.width,
139        OverlayAlign::Center => anchor.x + anchor.width / 2.0 - size.width / 2.0,
140    };
141    let along_y = || match options.align {
142        OverlayAlign::Start => anchor.y,
143        OverlayAlign::End => anchor.y + anchor.height - size.height,
144        OverlayAlign::Center => anchor.y + anchor.height / 2.0 - size.height / 2.0,
145    };
146    let (mut x, mut y) = match side {
147        OverlaySide::Top => (along_x(), anchor.y - offset - size.height),
148        OverlaySide::Bottom => (along_x(), anchor.y + anchor.height + offset),
149        OverlaySide::Left => (anchor.x - offset - size.width, along_y()),
150        OverlaySide::Right => (anchor.x + anchor.width + offset, along_y()),
151    };
152    if side.vertical() {
153        x = x.max(padding).min((viewport.width - padding - size.width).max(padding));
154    } else {
155        y = y.max(padding).min((viewport.height - padding - size.height).max(padding));
156    }
157    OverlayPlacement { x: round3(x), y: round3(y), side, flipped }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn a_flip_happens_only_when_the_opposite_side_has_more_room() {
166        let tight = place_overlay(
167            &Rect { x: 0.0, y: 290.0, width: 100.0, height: 20.0 },
168            &Size { width: 100.0, height: 400.0 },
169            &Size { width: 400.0, height: 600.0 },
170            &PlaceOptions::side(OverlaySide::Bottom),
171        );
172        assert!(!tight.flipped);
173        assert_eq!(tight.side, OverlaySide::Bottom);
174    }
175}