Skip to main content

sequoia_view/
overlay.rs

1use std::cell::Cell;
2
3use sequoia_core::{Point, Rect, Size};
4
5use crate::any::AnyView;
6use crate::runtime::ElementId;
7use crate::view::View;
8
9#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
10pub struct OverlayId(pub(crate) u64);
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
13#[non_exhaustive]
14pub enum OverlayLayer {
15    Tooltip,
16
17    Popup,
18
19    Menu,
20
21    DragPreview,
22
23    Modal,
24
25    System,
26}
27
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
29#[non_exhaustive]
30pub enum InputMode {
31    Passthrough,
32
33    #[default]
34    Interactive,
35
36    Modal,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
40#[non_exhaustive]
41pub enum Dismiss {
42    #[default]
43    Manual,
44
45    Escape,
46
47    OutsideClick,
48
49    FocusLost,
50}
51
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum OverlayOverflow {
55    #[default]
56    FlipAndClamp,
57
58    Clamp,
59
60    Visible,
61}
62
63#[derive(Clone, Copy, Debug, Default)]
64#[non_exhaustive]
65pub enum Anchor {
66    View(ElementId),
67
68    Rect(Rect),
69
70    Point(Point),
71
72    Cursor,
73
74    #[default]
75    Viewport,
76}
77
78#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79#[non_exhaustive]
80pub enum Position {
81    #[default]
82    Absolute,
83
84    Relative,
85
86    Fixed,
87}
88
89#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum Placement {
92    Top,
93
94    TopStart,
95
96    TopEnd,
97
98    #[default]
99    Bottom,
100
101    BottomStart,
102
103    BottomEnd,
104
105    Left,
106
107    Right,
108
109    Center,
110
111    Cursor,
112}
113
114pub struct OverlayEntry {
115    pub(crate) id: OverlayId,
116    pub(crate) view: AnyView,
117    pub(crate) anchor: Anchor,
118    pub(crate) placement: Placement,
119    pub(crate) position: Position,
120    pub(crate) overflow: OverlayOverflow,
121    pub(crate) layer: OverlayLayer,
122    pub(crate) input_mode: InputMode,
123    pub(crate) dismiss: Dismiss,
124
125    pub(crate) transient: bool,
126    pub(crate) offset: (f32, f32),
127    pub(crate) element: Cell<Option<ElementId>>,
128    pub(crate) rect: Cell<Rect>,
129}
130
131impl OverlayEntry {
132    pub fn new(view: impl View + 'static) -> Self {
133        Self {
134            id: OverlayId(0),
135            view: AnyView::new(view),
136            anchor: Anchor::default(),
137            placement: Placement::default(),
138            position: Position::default(),
139            overflow: OverlayOverflow::default(),
140            layer: OverlayLayer::Popup,
141            input_mode: InputMode::default(),
142            dismiss: Dismiss::default(),
143            transient: false,
144            offset: (0.0, 0.0),
145            element: Cell::new(None),
146            rect: Cell::new(Rect::ZERO),
147        }
148    }
149
150    pub fn anchor(mut self, anchor: Anchor) -> Self {
151        self.anchor = anchor;
152        self
153    }
154
155    pub fn placement(mut self, placement: Placement) -> Self {
156        self.placement = placement;
157        self
158    }
159
160    pub fn position(mut self, position: Position) -> Self {
161        self.position = position;
162        self
163    }
164
165    pub fn at(mut self, x: f32, y: f32) -> Self {
166        self.position = Position::Fixed;
167        self.offset = (x, y);
168        self
169    }
170
171    pub fn layer(mut self, layer: OverlayLayer) -> Self {
172        self.layer = layer;
173        self
174    }
175
176    pub fn input_mode(mut self, mode: InputMode) -> Self {
177        self.input_mode = mode;
178        self
179    }
180
181    pub fn dismiss(mut self, dismiss: Dismiss) -> Self {
182        self.dismiss = dismiss;
183        self
184    }
185
186    pub fn transient(mut self, transient: bool) -> Self {
187        self.transient = transient;
188        self
189    }
190
191    pub fn offset(mut self, dx: f32, dy: f32) -> Self {
192        self.offset = (dx, dy);
193        self
194    }
195
196    pub fn overflow(mut self, overflow: OverlayOverflow) -> Self {
197        self.overflow = overflow;
198        self
199    }
200}
201
202#[derive(Default)]
203pub(crate) struct OverlayStore {
204    entries: Vec<OverlayEntry>,
205    next_id: u64,
206    anchor_index: std::collections::HashMap<crate::runtime::ElementId, Vec<OverlayId>>,
207}
208
209impl OverlayStore {
210    pub(crate) fn insert(&mut self, mut entry: OverlayEntry) -> OverlayId {
211        let id = OverlayId(self.next_id);
212        self.next_id += 1;
213        entry.id = id;
214        if let Anchor::View(a) = entry.anchor {
215            self.anchor_index.entry(a).or_default().push(id);
216        }
217        self.entries.push(entry);
218        id
219    }
220
221    pub(crate) fn get(&self, id: OverlayId) -> Option<&OverlayEntry> {
222        self.entries.iter().find(|e| e.id == id)
223    }
224
225    pub(crate) fn remove(&mut self, id: OverlayId) -> Option<OverlayEntry> {
226        let pos = self.entries.iter().position(|e| e.id == id)?;
227        let entry = self.entries.remove(pos);
228        if let Anchor::View(a) = entry.anchor
229            && let Some(v) = self.anchor_index.get_mut(&a)
230        {
231            v.retain(|&x| x != id);
232            if v.is_empty() {
233                self.anchor_index.remove(&a);
234            }
235        }
236        Some(entry)
237    }
238
239    pub(crate) fn ordered_ids(&self) -> Vec<OverlayId> {
240        let mut ids: Vec<(OverlayLayer, usize, OverlayId)> = self
241            .entries
242            .iter()
243            .enumerate()
244            .map(|(i, e)| (e.layer, i, e.id))
245            .collect();
246        ids.sort_by_key(|(l, i, _)| (*l, *i));
247        ids.into_iter().map(|(_, _, id)| id).collect()
248    }
249
250    pub(crate) fn transient_ids(&self) -> Vec<OverlayId> {
251        self.entries
252            .iter()
253            .filter(|e| e.transient)
254            .map(|e| e.id)
255            .collect()
256    }
257
258    pub(crate) fn anchored_to(&self, el: crate::runtime::ElementId) -> Vec<OverlayId> {
259        if let Some(v) = self.anchor_index.get(&el) {
260            return v.clone();
261        }
262        self.entries
263            .iter()
264            .filter(|e| matches!(e.anchor, Anchor::View(a) if a == el))
265            .map(|e| e.id)
266            .collect()
267    }
268
269    pub(crate) fn outside_dismiss_ids(&self, pos: Point) -> Vec<OverlayId> {
270        self.entries
271            .iter()
272            .filter(|e| e.dismiss == Dismiss::OutsideClick && !e.rect.get().contains(pos))
273            .map(|e| e.id)
274            .collect()
275    }
276
277    pub(crate) fn escape_dismiss_id(&self) -> Option<OverlayId> {
278        self.ordered_ids()
279            .into_iter()
280            .rev()
281            .find(|id| self.get(*id).is_some_and(|e| e.dismiss == Dismiss::Escape))
282    }
283
284    pub(crate) fn focus_lost_ids(
285        &self,
286        target: Option<ElementId>,
287        inside: impl Fn(Option<ElementId>) -> bool,
288    ) -> Vec<OverlayId> {
289        self.entries
290            .iter()
291            .filter(|e| {
292                if e.dismiss != Dismiss::FocusLost {
293                    return false;
294                }
295                match target {
296                    None => true,
297                    Some(_) => !inside(e.element.get()),
298                }
299            })
300            .map(|e| e.id)
301            .collect()
302    }
303
304    pub(crate) fn len(&self) -> usize {
305        self.entries.len()
306    }
307
308    pub(crate) fn set_offset(&mut self, id: OverlayId, offset: (f32, f32)) -> bool {
309        match self.entries.iter_mut().find(|e| e.id == id) {
310            Some(e) => {
311                e.offset = offset;
312                true
313            }
314            None => false,
315        }
316    }
317}
318
319pub(crate) fn compute_position(
320    position: Position,
321    placement: Placement,
322    anchor: Rect,
323    size: Size,
324    viewport: Rect,
325    offset: (f32, f32),
326    overflow: OverlayOverflow,
327) -> Point {
328    match position {
329        Position::Absolute => {
330            placement_position(placement, anchor, size, viewport, offset, overflow)
331        }
332        Position::Relative => clamp_to_viewport(
333            Point::new(anchor.left() + offset.0, anchor.top() + offset.1),
334            size,
335            viewport,
336        ),
337        Position::Fixed => {
338            let pos = Point::new(viewport.left() + offset.0, viewport.top() + offset.1);
339            match overflow {
340                OverlayOverflow::Visible => pos,
341                _ => clamp_to_viewport(pos, size, viewport),
342            }
343        }
344    }
345}
346
347fn clamp_to_viewport(pos: Point, size: Size, viewport: Rect) -> Point {
348    let max_x = (viewport.right() - size.width).max(viewport.left());
349    let max_y = (viewport.bottom() - size.height).max(viewport.top());
350    Point::new(
351        pos.x.clamp(viewport.left(), max_x),
352        pos.y.clamp(viewport.top(), max_y),
353    )
354}
355
356pub(crate) fn placement_position(
357    placement: Placement,
358    anchor: Rect,
359    size: Size,
360    viewport: Rect,
361    offset: (f32, f32),
362    overflow: OverlayOverflow,
363) -> Point {
364    let placement = match overflow {
365        OverlayOverflow::FlipAndClamp => flip_if_needed(placement, anchor, size, viewport),
366        _ => placement,
367    };
368    let (ox, oy) = offset;
369    let (mut x, mut y) = match placement {
370        Placement::Top => (
371            anchor.left() + (anchor.size.width - size.width) * 0.5,
372            anchor.top() - size.height,
373        ),
374        Placement::TopStart => (anchor.left(), anchor.top() - size.height),
375        Placement::TopEnd => (anchor.right() - size.width, anchor.top() - size.height),
376        Placement::Bottom => (
377            anchor.left() + (anchor.size.width - size.width) * 0.5,
378            anchor.bottom(),
379        ),
380        Placement::BottomStart => (anchor.left(), anchor.bottom()),
381        Placement::BottomEnd => (anchor.right() - size.width, anchor.bottom()),
382        Placement::Left => (
383            anchor.left() - size.width,
384            anchor.top() + (anchor.size.height - size.height) * 0.5,
385        ),
386        Placement::Right => (
387            anchor.right(),
388            anchor.top() + (anchor.size.height - size.height) * 0.5,
389        ),
390        Placement::Center => (
391            anchor.left() + (anchor.size.width - size.width) * 0.5,
392            anchor.top() + (anchor.size.height - size.height) * 0.5,
393        ),
394        Placement::Cursor => (anchor.left(), anchor.top()),
395    };
396    x += ox;
397    y += oy;
398
399    match overflow {
400        OverlayOverflow::Visible => Point::new(x, y),
401        _ => clamp_to_viewport(Point::new(x, y), size, viewport),
402    }
403}
404
405fn flip_if_needed(placement: Placement, anchor: Rect, size: Size, viewport: Rect) -> Placement {
406    match placement {
407        Placement::Bottom | Placement::BottomStart | Placement::BottomEnd => {
408            let fits_below = anchor.bottom() + size.height <= viewport.bottom();
409            let fits_above =
410                anchor.top() - size.height >= viewport.top() && size.height <= viewport.size.height;
411            if !fits_below && fits_above {
412                match placement {
413                    Placement::BottomStart => Placement::TopStart,
414                    Placement::BottomEnd => Placement::TopEnd,
415                    _ => Placement::Top,
416                }
417            } else {
418                placement
419            }
420        }
421        Placement::Top | Placement::TopStart | Placement::TopEnd => {
422            let fits_above = anchor.top() - size.height >= viewport.top();
423            let fits_below = anchor.bottom() + size.height <= viewport.bottom()
424                && size.height <= viewport.size.height;
425            if !fits_above && fits_below {
426                match placement {
427                    Placement::TopStart => Placement::BottomStart,
428                    Placement::TopEnd => Placement::BottomEnd,
429                    _ => Placement::Bottom,
430                }
431            } else {
432                placement
433            }
434        }
435        Placement::Right => {
436            let fits_right = anchor.right() + size.width <= viewport.right();
437            let fits_left =
438                anchor.left() - size.width >= viewport.left() && size.width <= viewport.size.width;
439            if !fits_right && fits_left {
440                Placement::Left
441            } else {
442                placement
443            }
444        }
445        Placement::Left => {
446            let fits_left = anchor.left() - size.width >= viewport.left();
447            let fits_right = anchor.right() + size.width <= viewport.right()
448                && size.width <= viewport.size.width;
449            if !fits_left && fits_right {
450                Placement::Right
451            } else {
452                placement
453            }
454        }
455        Placement::Center | Placement::Cursor => placement,
456    }
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462
463    fn viewport() -> Rect {
464        Rect::from_xywh(0.0, 0.0, 800.0, 600.0)
465    }
466
467    #[test]
468    fn bottom_flips_to_top_when_it_does_not_fit() {
469        let anchor = Rect::from_xywh(100.0, 500.0, 120.0, 32.0);
470        let size = Size::new(200.0, 200.0);
471        let p = placement_position(
472            Placement::Bottom,
473            anchor,
474            size,
475            viewport(),
476            (0.0, 4.0),
477            OverlayOverflow::FlipAndClamp,
478        );
479        assert!(
480            p.y + size.height <= anchor.top() + 5.0,
481            "flipped above the anchor (plus 4px offset): {p:?}"
482        );
483    }
484
485    #[test]
486    fn clamp_keeps_placement_but_trims_to_viewport() {
487        let anchor = Rect::from_xywh(100.0, 500.0, 120.0, 32.0);
488        let size = Size::new(200.0, 200.0);
489        let p = placement_position(
490            Placement::Bottom,
491            anchor,
492            size,
493            viewport(),
494            (0.0, 4.0),
495            OverlayOverflow::Clamp,
496        );
497
498        assert!(
499            (p.y - 400.0).abs() < 1e-4,
500            "clamped to viewport without flip: {p:?}"
501        );
502        assert!(
503            p.y + size.height <= viewport().bottom() + 1.0,
504            "fits vertically: {p:?}"
505        );
506    }
507
508    #[test]
509    fn visible_may_bleed_outside_viewport() {
510        let anchor = Rect::from_xywh(100.0, 500.0, 120.0, 32.0);
511        let size = Size::new(200.0, 200.0);
512        let p = placement_position(
513            Placement::Bottom,
514            anchor,
515            size,
516            viewport(),
517            (0.0, 4.0),
518            OverlayOverflow::Visible,
519        );
520        assert!(
521            (p.y - (anchor.bottom() + 4.0)).abs() < 1e-4,
522            "exactly under the anchor, no fitting: {p:?}"
523        );
524        assert!(p.y + size.height > viewport().bottom(), "sticks out");
525    }
526
527    #[test]
528    fn fitting_popup_is_unaffected_by_policy() {
529        let anchor = Rect::from_xywh(100.0, 100.0, 120.0, 32.0);
530        let size = Size::new(200.0, 120.0);
531        for policy in [
532            OverlayOverflow::FlipAndClamp,
533            OverlayOverflow::Clamp,
534            OverlayOverflow::Visible,
535        ] {
536            let p = placement_position(
537                Placement::Bottom,
538                anchor,
539                size,
540                viewport(),
541                (0.0, 4.0),
542                policy,
543            );
544            assert!(
545                (p.y - (anchor.bottom() + 4.0)).abs() < 1e-4,
546                "{policy:?}: unchanged when it fits: {p:?}"
547            );
548        }
549    }
550}