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 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 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 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 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}