Skip to main content

gpui_rhai/
overlay.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::time::{Duration, Instant};
3
4use thiserror::Error;
5
6#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct OverlayId(String);
8
9impl OverlayId {
10    #[must_use]
11    pub fn new(value: impl Into<String>) -> Self {
12        Self(value.into())
13    }
14
15    #[must_use]
16    pub fn as_str(&self) -> &str {
17        &self.0
18    }
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22pub enum OverlayKind {
23    Popover,
24    Combobox,
25    Tooltip,
26    Dialog,
27    Sheet,
28    Menu,
29}
30
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub enum OverlayPlacement {
33    Top,
34    Bottom,
35    Left,
36    Right,
37    Start,
38    End,
39    Center,
40}
41
42impl OverlayPlacement {
43    const fn opposite(self) -> Self {
44        match self {
45            Self::Top => Self::Bottom,
46            Self::Bottom => Self::Top,
47            Self::Left => Self::Right,
48            Self::Right => Self::Left,
49            Self::Start => Self::End,
50            Self::End => Self::Start,
51            Self::Center => Self::Center,
52        }
53    }
54}
55
56#[derive(Clone, Copy, Debug, Default, PartialEq)]
57pub struct OverlayBounds {
58    pub x: f64,
59    pub y: f64,
60    pub width: f64,
61    pub height: f64,
62}
63
64impl OverlayBounds {
65    #[must_use]
66    pub fn contains(self, x: f64, y: f64) -> bool {
67        x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height
68    }
69
70    fn clamp_to(self, viewport: Self) -> Self {
71        let max_x = (viewport.x + viewport.width - self.width).max(viewport.x);
72        let max_y = (viewport.y + viewport.height - self.height).max(viewport.y);
73        Self {
74            x: self.x.clamp(viewport.x, max_x),
75            y: self.y.clamp(viewport.y, max_y),
76            width: self.width.min(viewport.width),
77            height: self.height.min(viewport.height),
78        }
79    }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq)]
83pub struct FocusToken(pub String);
84
85#[derive(Clone, Debug)]
86pub struct OverlaySpec {
87    pub id: OverlayId,
88    pub parent: Option<OverlayId>,
89    pub kind: OverlayKind,
90    pub anchor: OverlayBounds,
91    pub width: f64,
92    pub height: f64,
93    pub preferred: OverlayPlacement,
94    pub gap: f64,
95    pub modal: bool,
96    pub dismiss_on_escape: bool,
97    pub dismiss_on_outside: bool,
98    pub restore_focus: Option<FocusToken>,
99}
100
101#[derive(Clone, Copy, Debug, PartialEq)]
102pub struct PlacementResult {
103    pub bounds: OverlayBounds,
104    pub placement: OverlayPlacement,
105    pub flipped: bool,
106}
107
108#[derive(Clone, Debug)]
109struct OverlayEntry {
110    spec: OverlaySpec,
111    placement: PlacementResult,
112    z_index: u64,
113}
114
115#[derive(Clone, Debug)]
116pub struct OverlayManager {
117    viewport: OverlayBounds,
118    entries: BTreeMap<OverlayId, OverlayEntry>,
119    order: Vec<OverlayId>,
120    next_z: u64,
121    tooltips: TooltipScheduler,
122}
123
124impl OverlayManager {
125    /// Create a per-window manager with a valid viewport.
126    ///
127    /// # Errors
128    ///
129    /// Returns [`OverlayError::InvalidGeometry`] for invalid bounds.
130    pub fn new(viewport: OverlayBounds) -> Result<Self, OverlayError> {
131        validate_bounds(viewport)?;
132        Ok(Self {
133            viewport,
134            entries: BTreeMap::new(),
135            order: Vec::new(),
136            next_z: 1,
137            tooltips: TooltipScheduler::default(),
138        })
139    }
140
141    /// Start a new render frame while preserving tooltip and toast state.
142    ///
143    /// # Errors
144    ///
145    /// Returns [`OverlayError::InvalidGeometry`] for invalid viewport bounds.
146    pub fn begin_frame(&mut self, viewport: OverlayBounds) -> Result<(), OverlayError> {
147        validate_bounds(viewport)?;
148        self.viewport = viewport;
149        self.entries.clear();
150        self.order.clear();
151        self.next_z = 1;
152        Ok(())
153    }
154
155    pub(crate) fn remove_id_prefix(&mut self, prefix: &str) {
156        self.entries
157            .retain(|id, _| !id.as_str().starts_with(prefix));
158        self.order.retain(|id| !id.as_str().starts_with(prefix));
159    }
160
161    /// Open and place an overlay above its optional parent.
162    ///
163    /// # Errors
164    ///
165    /// Returns duplicate, missing-parent, cycle, or geometry errors.
166    pub fn open(&mut self, spec: OverlaySpec) -> Result<PlacementResult, OverlayError> {
167        if self.entries.contains_key(&spec.id) {
168            return Err(OverlayError::Duplicate(spec.id));
169        }
170        validate_bounds(spec.anchor)?;
171        validate_size(spec.width, spec.height, spec.gap)?;
172        if let Some(parent) = &spec.parent {
173            if parent == &spec.id {
174                return Err(OverlayError::ParentCycle(spec.id));
175            }
176            if !self.entries.contains_key(parent) {
177                return Err(OverlayError::MissingParent(parent.clone()));
178            }
179        }
180        let placement = place(&spec, self.viewport);
181        let id = spec.id.clone();
182        self.entries.insert(
183            id.clone(),
184            OverlayEntry {
185                spec,
186                placement,
187                z_index: self.next_z,
188            },
189        );
190        self.next_z = self.next_z.saturating_add(1);
191        self.order.push(id);
192        Ok(placement)
193    }
194
195    /// Update window bounds and recompute every placement.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`OverlayError::InvalidGeometry`] for invalid viewport bounds.
200    pub fn set_viewport(&mut self, viewport: OverlayBounds) -> Result<(), OverlayError> {
201        validate_bounds(viewport)?;
202        self.viewport = viewport;
203        for entry in self.entries.values_mut() {
204            entry.placement = place(&entry.spec, viewport);
205        }
206        Ok(())
207    }
208
209    #[must_use]
210    pub fn placement(&self, id: &OverlayId) -> Option<PlacementResult> {
211        self.entries.get(id).map(|entry| entry.placement)
212    }
213
214    #[must_use]
215    pub fn z_index(&self, id: &OverlayId) -> Option<u64> {
216        self.entries.get(id).map(|entry| entry.z_index)
217    }
218
219    #[must_use]
220    pub fn dismiss(&mut self, id: &OverlayId) -> DismissReport {
221        let mut removed = BTreeSet::new();
222        self.collect_descendants(id, &mut removed);
223        removed.insert(id.clone());
224        let mut dismissed = self
225            .order
226            .iter()
227            .filter(|candidate| removed.contains(*candidate))
228            .cloned()
229            .collect::<Vec<_>>();
230        dismissed.reverse();
231        let restore_focus = dismissed.iter().find_map(|dismissed| {
232            self.entries
233                .get(dismissed)
234                .and_then(|entry| entry.spec.restore_focus.clone())
235        });
236        for dismissed in &dismissed {
237            self.entries.remove(dismissed);
238        }
239        self.order.retain(|candidate| !removed.contains(candidate));
240        DismissReport {
241            dismissed,
242            restore_focus,
243        }
244    }
245
246    #[must_use]
247    pub fn handle_escape(&mut self) -> DismissReport {
248        let candidate = self.order.iter().rev().find(|id| {
249            self.entries
250                .get(*id)
251                .is_some_and(|entry| entry.spec.dismiss_on_escape)
252        });
253        candidate
254            .cloned()
255            .map_or_else(DismissReport::default, |id| self.dismiss(&id))
256    }
257
258    #[must_use]
259    pub fn handle_outside_click(&mut self, x: f64, y: f64) -> DismissReport {
260        let containing = self.order.iter().rev().find(|id| {
261            self.entries.get(*id).is_some_and(|entry| {
262                entry.placement.bounds.contains(x, y) || entry.spec.anchor.contains(x, y)
263            })
264        });
265        if let Some(containing) = containing {
266            let ancestors = self.ancestors(containing);
267            let candidates = self
268                .order
269                .iter()
270                .rev()
271                .filter(|id| !ancestors.contains(*id))
272                .filter(|id| {
273                    self.entries
274                        .get(*id)
275                        .is_some_and(|entry| entry.spec.dismiss_on_outside && !entry.spec.modal)
276                })
277                .cloned()
278                .collect::<Vec<_>>();
279            return candidates
280                .first()
281                .map_or_else(DismissReport::default, |id| self.dismiss(id));
282        }
283        let candidate = self.order.iter().rev().find(|id| {
284            self.entries
285                .get(*id)
286                .is_some_and(|entry| entry.spec.dismiss_on_outside || entry.spec.modal)
287        });
288        candidate
289            .cloned()
290            .map_or_else(DismissReport::default, |id| {
291                if self
292                    .entries
293                    .get(&id)
294                    .is_some_and(|entry| entry.spec.dismiss_on_outside)
295                {
296                    self.dismiss(&id)
297                } else {
298                    DismissReport::default()
299                }
300            })
301    }
302
303    #[must_use]
304    pub fn tooltips(&self) -> &TooltipScheduler {
305        &self.tooltips
306    }
307
308    pub fn tooltips_mut(&mut self) -> &mut TooltipScheduler {
309        &mut self.tooltips
310    }
311
312    fn collect_descendants(&self, id: &OverlayId, output: &mut BTreeSet<OverlayId>) {
313        for (candidate, entry) in &self.entries {
314            if entry.spec.parent.as_ref() == Some(id) && output.insert(candidate.clone()) {
315                self.collect_descendants(candidate, output);
316            }
317        }
318    }
319
320    fn ancestors(&self, id: &OverlayId) -> BTreeSet<OverlayId> {
321        let mut ancestors = BTreeSet::from([id.clone()]);
322        let mut current = self
323            .entries
324            .get(id)
325            .and_then(|entry| entry.spec.parent.clone());
326        while let Some(parent) = current {
327            ancestors.insert(parent.clone());
328            current = self
329                .entries
330                .get(&parent)
331                .and_then(|entry| entry.spec.parent.clone());
332        }
333        ancestors
334    }
335}
336
337#[derive(Clone, Debug, Default)]
338pub struct TooltipScheduler {
339    visible: Option<OverlayId>,
340    pending_show: Option<(OverlayId, Instant)>,
341    pending_hide: Option<(OverlayId, Instant)>,
342}
343
344impl TooltipScheduler {
345    pub fn pointer_enter(&mut self, id: OverlayId, now: Instant, delay: Duration) {
346        self.pending_hide = None;
347        if self.visible.as_ref() == Some(&id) {
348            self.pending_show = None;
349        } else {
350            self.pending_show = Some((id, now + delay));
351        }
352    }
353
354    pub fn pointer_leave(&mut self, id: &OverlayId, now: Instant, delay: Duration) {
355        if self
356            .pending_show
357            .as_ref()
358            .is_some_and(|(pending, _)| pending == id)
359        {
360            self.pending_show = None;
361        }
362        if self.visible.as_ref() == Some(id) {
363            self.pending_hide = Some((id.clone(), now + delay));
364        }
365    }
366
367    #[must_use]
368    pub fn tick(&mut self, now: Instant) -> TooltipTransition {
369        let hidden = self
370            .pending_hide
371            .as_ref()
372            .filter(|(_, deadline)| now >= *deadline)
373            .map(|(id, _)| id.clone());
374        if let Some(hidden) = &hidden {
375            if self.visible.as_ref() == Some(hidden) {
376                self.visible = None;
377            }
378            self.pending_hide = None;
379        }
380        let shown = self
381            .pending_show
382            .as_ref()
383            .filter(|(_, deadline)| now >= *deadline)
384            .map(|(id, _)| id.clone());
385        if let Some(shown) = &shown {
386            self.visible = Some(shown.clone());
387            self.pending_show = None;
388        }
389        TooltipTransition { shown, hidden }
390    }
391
392    #[must_use]
393    pub fn visible(&self) -> Option<&OverlayId> {
394        self.visible.as_ref()
395    }
396
397    pub fn remove(&mut self, id: &OverlayId) -> bool {
398        let mut removed = false;
399        if self.visible.as_ref() == Some(id) {
400            self.visible = None;
401            removed = true;
402        }
403        if self
404            .pending_show
405            .as_ref()
406            .is_some_and(|(pending, _)| pending == id)
407        {
408            self.pending_show = None;
409            removed = true;
410        }
411        if self
412            .pending_hide
413            .as_ref()
414            .is_some_and(|(pending, _)| pending == id)
415        {
416            self.pending_hide = None;
417            removed = true;
418        }
419        removed
420    }
421}
422
423#[derive(Clone, Debug, Default, Eq, PartialEq)]
424pub struct TooltipTransition {
425    pub shown: Option<OverlayId>,
426    pub hidden: Option<OverlayId>,
427}
428
429fn place(spec: &OverlaySpec, viewport: OverlayBounds) -> PlacementResult {
430    let preferred = candidate_bounds(spec, spec.preferred, viewport);
431    if fits_primary_axis(preferred, viewport, spec.preferred) {
432        return PlacementResult {
433            bounds: preferred.clamp_to(viewport),
434            placement: spec.preferred,
435            flipped: false,
436        };
437    }
438    let opposite = spec.preferred.opposite();
439    let flipped = candidate_bounds(spec, opposite, viewport);
440    if fits_primary_axis(flipped, viewport, opposite) {
441        PlacementResult {
442            bounds: flipped.clamp_to(viewport),
443            placement: opposite,
444            flipped: true,
445        }
446    } else {
447        PlacementResult {
448            bounds: preferred.clamp_to(viewport),
449            placement: spec.preferred,
450            flipped: false,
451        }
452    }
453}
454
455fn fits_primary_axis(
456    bounds: OverlayBounds,
457    viewport: OverlayBounds,
458    placement: OverlayPlacement,
459) -> bool {
460    match placement {
461        OverlayPlacement::Top | OverlayPlacement::Bottom => {
462            bounds.y >= viewport.y && bounds.y + bounds.height <= viewport.y + viewport.height
463        }
464        OverlayPlacement::Left
465        | OverlayPlacement::Right
466        | OverlayPlacement::Start
467        | OverlayPlacement::End => {
468            bounds.x >= viewport.x && bounds.x + bounds.width <= viewport.x + viewport.width
469        }
470        OverlayPlacement::Center => true,
471    }
472}
473
474fn candidate_bounds(
475    spec: &OverlaySpec,
476    placement: OverlayPlacement,
477    viewport: OverlayBounds,
478) -> OverlayBounds {
479    match placement {
480        OverlayPlacement::Top => OverlayBounds {
481            x: spec.anchor.x + (spec.anchor.width - spec.width) / 2.0,
482            y: spec.anchor.y - spec.height - spec.gap,
483            width: spec.width,
484            height: spec.height,
485        },
486        OverlayPlacement::Bottom => OverlayBounds {
487            x: spec.anchor.x + (spec.anchor.width - spec.width) / 2.0,
488            y: spec.anchor.y + spec.anchor.height + spec.gap,
489            width: spec.width,
490            height: spec.height,
491        },
492        OverlayPlacement::Left => OverlayBounds {
493            x: spec.anchor.x - spec.width - spec.gap,
494            y: spec.anchor.y + (spec.anchor.height - spec.height) / 2.0,
495            width: spec.width,
496            height: spec.height,
497        },
498        OverlayPlacement::Right => OverlayBounds {
499            x: spec.anchor.x + spec.anchor.width + spec.gap,
500            y: spec.anchor.y + (spec.anchor.height - spec.height) / 2.0,
501            width: spec.width,
502            height: spec.height,
503        },
504        OverlayPlacement::Start => candidate_bounds(spec, OverlayPlacement::Left, viewport),
505        OverlayPlacement::End => candidate_bounds(spec, OverlayPlacement::Right, viewport),
506        OverlayPlacement::Center => OverlayBounds {
507            x: viewport.x + (viewport.width - spec.width) / 2.0,
508            y: viewport.y + (viewport.height - spec.height) / 2.0,
509            width: spec.width,
510            height: spec.height,
511        },
512    }
513}
514
515fn validate_bounds(bounds: OverlayBounds) -> Result<(), OverlayError> {
516    validate_size(bounds.width, bounds.height, 0.0)?;
517    if bounds.x.is_finite() && bounds.y.is_finite() {
518        Ok(())
519    } else {
520        Err(OverlayError::InvalidGeometry)
521    }
522}
523
524fn validate_size(width: f64, height: f64, gap: f64) -> Result<(), OverlayError> {
525    if width.is_finite()
526        && width >= 0.0
527        && height.is_finite()
528        && height >= 0.0
529        && gap.is_finite()
530        && gap >= 0.0
531    {
532        Ok(())
533    } else {
534        Err(OverlayError::InvalidGeometry)
535    }
536}
537
538#[derive(Clone, Debug, Default, Eq, PartialEq)]
539pub struct DismissReport {
540    pub dismissed: Vec<OverlayId>,
541    pub restore_focus: Option<FocusToken>,
542}
543
544#[derive(Clone, Debug, Error, Eq, PartialEq)]
545pub enum OverlayError {
546    #[error("overlay geometry must be finite and non-negative")]
547    InvalidGeometry,
548    #[error("overlay `{0:?}` is already open")]
549    Duplicate(OverlayId),
550    #[error("overlay parent `{0:?}` is not open")]
551    MissingParent(OverlayId),
552    #[error("overlay `{0:?}` cannot parent itself")]
553    ParentCycle(OverlayId),
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559
560    fn viewport() -> OverlayBounds {
561        OverlayBounds {
562            x: 0.0,
563            y: 0.0,
564            width: 800.0,
565            height: 600.0,
566        }
567    }
568
569    fn spec(id: &str, parent: Option<&str>, anchor: OverlayBounds) -> OverlaySpec {
570        OverlaySpec {
571            id: OverlayId::new(id),
572            parent: parent.map(OverlayId::new),
573            kind: OverlayKind::Popover,
574            anchor,
575            width: 180.0,
576            height: 120.0,
577            preferred: OverlayPlacement::Bottom,
578            gap: 8.0,
579            modal: false,
580            dismiss_on_escape: true,
581            dismiss_on_outside: true,
582            restore_focus: Some(FocusToken(format!("focus-{id}"))),
583        }
584    }
585
586    #[test]
587    fn placement_flips_then_clamps_to_window() {
588        let mut manager = OverlayManager::new(viewport()).unwrap();
589        let result = manager
590            .open(spec(
591                "menu",
592                None,
593                OverlayBounds {
594                    x: 700.0,
595                    y: 560.0,
596                    width: 80.0,
597                    height: 30.0,
598                },
599            ))
600            .unwrap();
601        assert_eq!(result.placement, OverlayPlacement::Top);
602        assert!(result.flipped);
603        assert!(result.bounds.x >= 0.0 && result.bounds.x + result.bounds.width <= 800.0);
604        assert!(result.bounds.y >= 0.0 && result.bounds.y + result.bounds.height <= 600.0);
605    }
606
607    #[test]
608    fn centered_dialog_ignores_anchor_and_fits_viewport() {
609        let mut manager = OverlayManager::new(viewport()).unwrap();
610        let mut dialog = spec(
611            "dialog",
612            None,
613            OverlayBounds {
614                x: 710.0,
615                y: 570.0,
616                width: 40.0,
617                height: 20.0,
618            },
619        );
620        dialog.kind = OverlayKind::Dialog;
621        dialog.preferred = OverlayPlacement::Center;
622        dialog.width = 320.0;
623        dialog.height = 200.0;
624        let result = manager.open(dialog).unwrap();
625        assert_eq!(result.placement, OverlayPlacement::Center);
626        assert!((result.bounds.x - 240.0).abs() < f64::EPSILON);
627        assert!((result.bounds.y - 200.0).abs() < f64::EPSILON);
628    }
629
630    #[test]
631    fn clicking_trigger_is_not_an_outside_dismissal() {
632        let mut manager = OverlayManager::new(viewport()).unwrap();
633        manager
634            .open(spec(
635                "popover",
636                None,
637                OverlayBounds {
638                    x: 100.0,
639                    y: 100.0,
640                    width: 80.0,
641                    height: 30.0,
642                },
643            ))
644            .unwrap();
645        let report = manager.handle_outside_click(120.0, 110.0);
646        assert!(report.dismissed.is_empty());
647        assert!(manager.z_index(&OverlayId::new("popover")).is_some());
648    }
649
650    #[test]
651    fn tooltip_delay_cancels_on_leave_and_transitions_deterministically() {
652        let start = Instant::now();
653        let mut tooltips = TooltipScheduler::default();
654        let id = OverlayId::new("help");
655        tooltips.pointer_enter(id.clone(), start, Duration::from_millis(300));
656        assert_eq!(
657            tooltips.tick(start + Duration::from_millis(299)),
658            TooltipTransition::default()
659        );
660        tooltips.pointer_leave(&id, start + Duration::from_millis(299), Duration::ZERO);
661        assert_eq!(
662            tooltips.tick(start + Duration::from_millis(300)),
663            TooltipTransition::default()
664        );
665        tooltips.pointer_enter(id.clone(), start, Duration::from_millis(100));
666        assert_eq!(
667            tooltips.tick(start + Duration::from_millis(100)).shown,
668            Some(id.clone())
669        );
670        tooltips.pointer_leave(&id, start, Duration::from_millis(50));
671        assert_eq!(
672            tooltips.tick(start + Duration::from_millis(50)).hidden,
673            Some(id)
674        );
675    }
676
677    #[test]
678    fn dismissing_parent_closes_nested_children_top_down() {
679        let mut manager = OverlayManager::new(viewport()).unwrap();
680        let anchor = OverlayBounds {
681            x: 100.0,
682            y: 100.0,
683            width: 80.0,
684            height: 30.0,
685        };
686        manager.open(spec("parent", None, anchor)).unwrap();
687        manager.open(spec("child", Some("parent"), anchor)).unwrap();
688        let report = manager.dismiss(&OverlayId::new("parent"));
689        assert_eq!(
690            report.dismissed,
691            vec![OverlayId::new("child"), OverlayId::new("parent")]
692        );
693        assert_eq!(
694            report.restore_focus,
695            Some(FocusToken("focus-child".to_owned()))
696        );
697    }
698
699    #[test]
700    fn escape_closes_only_topmost_dismissible_overlay() {
701        let mut manager = OverlayManager::new(viewport()).unwrap();
702        let anchor = OverlayBounds {
703            x: 100.0,
704            y: 100.0,
705            width: 80.0,
706            height: 30.0,
707        };
708        manager.open(spec("first", None, anchor)).unwrap();
709        manager.open(spec("second", None, anchor)).unwrap();
710        assert_eq!(
711            manager.handle_escape().dismissed,
712            vec![OverlayId::new("second")]
713        );
714        assert!(manager.placement(&OverlayId::new("first")).is_some());
715    }
716}