1use super::*;
5
6use crate::accessibility::AccessNodeBuilder;
7
8impl WidgetTree {
9 pub fn attach_tooltip(
12 &mut self,
13 anchor_id: WidgetId,
14 content_id: WidgetId,
15 delay: std::time::Duration,
16 ) {
17 self.attach_tooltip_inner(
18 anchor_id,
19 content_id,
20 delay,
21 None,
22 None,
23 crate::overlay::TooltipPlacement::Below,
24 );
25 }
26
27 pub fn attach_tooltip_with_placement(
32 &mut self,
33 anchor_id: WidgetId,
34 content_id: WidgetId,
35 delay: std::time::Duration,
36 placement: crate::overlay::TooltipPlacement,
37 ) {
38 self.attach_tooltip_inner(anchor_id, content_id, delay, None, None, placement);
39 }
40
41 pub fn attach_tooltip_with_sticky(
53 &mut self,
54 anchor_id: WidgetId,
55 content_id: WidgetId,
56 delay: std::time::Duration,
57 sticky_after: Option<std::time::Duration>,
58 ) {
59 self.attach_tooltip_inner(
60 anchor_id,
61 content_id,
62 delay,
63 sticky_after,
64 None,
65 crate::overlay::TooltipPlacement::Below,
66 );
67 }
68
69 pub fn attach_tooltip_with_sticky_sink(
75 &mut self,
76 anchor_id: WidgetId,
77 content_id: WidgetId,
78 delay: std::time::Duration,
79 sticky_after: Option<std::time::Duration>,
80 shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
81 ) {
82 self.attach_tooltip_inner(
83 anchor_id,
84 content_id,
85 delay,
86 sticky_after,
87 Some(shown_at_sink),
88 crate::overlay::TooltipPlacement::Below,
89 );
90 }
91
92 pub fn attach_tooltip_with_sticky_sink_placement(
97 &mut self,
98 anchor_id: WidgetId,
99 content_id: WidgetId,
100 delay: std::time::Duration,
101 sticky_after: Option<std::time::Duration>,
102 shown_at_sink: std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>,
103 placement: crate::overlay::TooltipPlacement,
104 ) {
105 self.attach_tooltip_inner(
106 anchor_id,
107 content_id,
108 delay,
109 sticky_after,
110 Some(shown_at_sink),
111 placement,
112 );
113 }
114
115 fn retire_tooltips_for_anchor(&mut self, anchor_id: WidgetId, keep_content_id: WidgetId) {
130 let stale: Vec<(WidgetId, Option<crate::overlay::OverlayId>)> = self
131 .tooltips
132 .iter()
133 .filter(|entry| entry.anchor_id == anchor_id && entry.content_id != keep_content_id)
134 .map(|entry| (entry.content_id, entry.overlay_id))
135 .collect();
136 if stale.is_empty() {
137 return;
138 }
139 self.tooltips
140 .retain(|entry| entry.anchor_id != anchor_id || entry.content_id == keep_content_id);
141 for (content_id, overlay_id) in stale {
142 if let Some(overlay_id) = overlay_id {
146 self.dismiss_overlay(overlay_id);
147 }
148 self.destroy_subtree(content_id);
149 }
150 }
151
152 pub(super) fn retire_tooltips_of_destroyed_anchor(&mut self, anchor_id: WidgetId) {
159 let stale: Vec<(WidgetId, Option<crate::overlay::OverlayId>)> = self
160 .tooltips
161 .iter()
162 .filter(|entry| entry.anchor_id == anchor_id)
163 .map(|entry| (entry.content_id, entry.overlay_id))
164 .collect();
165 if stale.is_empty() {
166 return;
167 }
168 self.tooltips.retain(|entry| entry.anchor_id != anchor_id);
169 for (content_id, overlay_id) in stale {
170 if let Some(overlay_id) = overlay_id {
171 self.dismiss_overlay(overlay_id);
172 }
173 self.destroy_subtree(content_id);
174 }
175 }
176
177 fn attach_tooltip_inner(
178 &mut self,
179 anchor_id: WidgetId,
180 content_id: WidgetId,
181 delay: std::time::Duration,
182 sticky_after: Option<std::time::Duration>,
183 shown_at_sink: Option<std::rc::Rc<std::cell::Cell<Option<std::time::Instant>>>>,
184 placement: crate::overlay::TooltipPlacement,
185 ) {
186 self.retire_tooltips_for_anchor(anchor_id, content_id);
187 if let Some(entry) = self
190 .tooltips
191 .iter_mut()
192 .find(|entry| entry.anchor_id == anchor_id && entry.content_id == content_id)
193 {
194 entry.delay = delay;
195 entry.sticky_after = sticky_after;
196 entry.placement = placement;
197 if shown_at_sink.is_some() {
198 entry.shown_at_sink = shown_at_sink;
199 }
200 return;
201 }
202 self.arena.set_dormant(content_id);
203 self.tooltips.push(TooltipEntry {
204 anchor_id,
205 content_id,
206 description_owner_id: anchor_id,
210 delay,
211 hover_start: None,
212 real_hover_start: None,
213 hover_origin: None,
214 overlay_id: None,
215 sticky_after,
216 is_sticky: false,
217 shown_at_sim: None,
218 shown_at_real: None,
219 shown_at_sink,
220 promoted_by_focus: false,
221 armed_by_focus: false,
222 suppressed_until_focus_leaves: false,
223 placement,
224 });
225 }
226
227 pub(crate) fn set_tooltip_description_owner(
246 &mut self,
247 anchor_id: WidgetId,
248 owner_id: WidgetId,
249 ) {
250 if let Some(entry) = self
251 .tooltips
252 .iter_mut()
253 .find(|entry| entry.anchor_id == anchor_id)
254 {
255 entry.description_owner_id = owner_id;
256 }
257 }
258
259 fn tooltip_overlay_placement(
266 placement: crate::overlay::TooltipPlacement,
267 ) -> crate::overlay::OverlayPlacement {
268 match placement {
269 crate::overlay::TooltipPlacement::Below => {
270 crate::overlay::OverlayPlacement::NearAnchor {
271 offset: teksilo_canvas::Vec2::new(0.0, 8.0),
272 }
273 }
274 crate::overlay::TooltipPlacement::Side => {
275 crate::overlay::OverlayPlacement::TrailingEdge
276 }
277 }
278 }
279
280 pub(super) fn process_tooltips(&mut self) {
281 let sim_now = self.sim_clock;
282 let session_active = self.tooltip_session_active_sim(sim_now);
283 self.process_tooltips_impl(
284 |entry| {
285 entry
286 .hover_start
287 .map(|start| sim_now.saturating_duration_since(start))
288 },
289 session_active,
290 true,
291 );
292 }
293
294 pub(super) fn process_tooltips_real(&mut self) {
295 let real_now = std::time::Instant::now();
296 let session_active = self.tooltip_session_active_real(real_now);
297 self.process_tooltips_impl(
298 |entry| {
299 entry
300 .real_hover_start
301 .map(|start| real_now.saturating_duration_since(start))
302 },
303 session_active,
304 false,
305 );
306 }
307
308 fn any_tooltip_in_session(&self) -> bool {
317 self.tooltips
318 .iter()
319 .any(|e| e.overlay_id.is_some() && !e.is_sticky)
320 }
321
322 fn tooltip_session_active_sim(&self, now: std::time::Instant) -> bool {
324 self.any_tooltip_in_session()
325 || self
326 .tooltip_session_until_sim
327 .is_some_and(|until| now < until)
328 }
329
330 fn tooltip_session_active_real(&self, now: std::time::Instant) -> bool {
332 self.any_tooltip_in_session()
333 || self
334 .tooltip_session_until_real
335 .is_some_and(|until| now < until)
336 }
337
338 fn effective_tooltip_delay(
352 &self,
353 entry_delay: std::time::Duration,
354 session_active: bool,
355 ) -> std::time::Duration {
356 if !session_active {
357 return entry_delay;
358 }
359 let motion = &self.theme.motion;
360 let base = motion.tooltip_delay.as_secs_f64();
361 if base <= 0.0 {
362 return entry_delay.min(motion.tooltip_reshow_delay);
365 }
366 let ratio = motion.tooltip_reshow_delay.as_secs_f64() / base;
367 entry_delay.mul_f64(ratio).min(entry_delay)
369 }
370
371 fn process_tooltips_impl(
372 &mut self,
373 elapsed_fn: impl Fn(&TooltipEntry) -> Option<std::time::Duration>,
374 session_active: bool,
375 use_sim_clock: bool,
376 ) {
377 let dismissed_indices: Vec<usize> = self
383 .tooltips
384 .iter()
385 .enumerate()
386 .filter_map(|(i, e)| match e.overlay_id {
387 Some(oid) if !self.overlay_manager.stack.iter().any(|o| o.id == oid) => Some(i),
388 _ => None,
389 })
390 .collect();
391 let any_dismissed = !dismissed_indices.is_empty();
392 for i in dismissed_indices {
393 let e = &mut self.tooltips[i];
394 e.overlay_id = None;
395 e.is_sticky = false;
396 e.promoted_by_focus = false;
397 e.shown_at_sim = None;
398 e.shown_at_real = None;
399 e.hover_origin = None;
400 if let Some(sink) = e.shown_at_sink.as_ref() {
401 sink.set(None);
402 }
403 }
404 if any_dismissed {
407 let grace = super::TOOLTIP_SESSION_GRACE;
408 if use_sim_clock {
409 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
410 } else {
411 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
412 }
413 }
414
415 let session_active = session_active
420 || self.any_tooltip_in_session()
421 || if use_sim_clock {
422 self.tooltip_session_active_sim(self.sim_clock)
423 } else {
424 self.tooltip_session_active_real(std::time::Instant::now())
425 };
426
427 let mut to_show = Vec::new();
428 let pending: Vec<usize> = self
431 .tooltips
432 .iter()
433 .enumerate()
434 .filter(|(_, entry)| entry.overlay_id.is_none())
435 .filter(|(_, entry)| self.arena.is_active(entry.anchor_id))
436 .filter_map(|(i, entry)| {
437 let delay = self.effective_tooltip_delay(entry.delay, session_active);
438 let elapsed = elapsed_fn(entry)?;
439 (elapsed >= delay).then_some(i)
440 })
441 .collect();
442 for index in pending {
443 let content_id = self.tooltips[index].content_id;
448 self.materialize_deferred(content_id);
449 let body_id = self.tooltip_content_node(content_id);
460 if !self
461 .arena
462 .get(body_id)
463 .is_none_or(|node| node.widget.tooltip_has_content())
464 {
465 let entry = &mut self.tooltips[index];
466 entry.hover_start = None;
467 entry.real_hover_start = None;
468 entry.hover_origin = None;
469 continue;
470 }
471 let entry = &mut self.tooltips[index];
472 to_show.push((
473 entry.anchor_id,
474 entry.content_id,
475 entry.placement,
476 entry.armed_by_focus,
477 ));
478 entry.hover_start = None;
479 entry.real_hover_start = None;
480 entry.hover_origin = None;
481 }
482 let sim_now = self.sim_clock;
483 let real_now = std::time::Instant::now();
484 let fade_duration = if self.prefers_reduced_motion || session_active {
491 None
492 } else {
493 Some(self.theme.motion.duration_fast)
494 };
495 for (anchor_id, content_id, placement, by_focus) in to_show {
496 self.arena.activate(content_id);
497 let dismiss = if by_focus {
502 crate::overlay::DismissBehavior::EscapeOrClickOutside
503 } else {
504 crate::overlay::DismissBehavior::PointerLeave {
505 delay: std::time::Duration::from_millis(100),
506 }
507 };
508 let oid = self.show_overlay(crate::overlay::OverlayRequest {
509 content_id,
510 anchor: anchor_id,
511 placement: Self::tooltip_overlay_placement(placement),
512 dismiss,
513 layer: crate::overlay::OverlayLayer::InTree,
514 parent_overlay: None,
515 on_dismiss: None,
516 fade_duration,
517 });
518 if by_focus && let Some(focused) = self.focused {
519 self.overlay_manager.set_top_focus_restore(focused);
520 }
521 if let Some(entry) = self
522 .tooltips
523 .iter_mut()
524 .find(|e| e.content_id == content_id)
525 {
526 entry.overlay_id = Some(oid);
527 entry.shown_at_sim = Some(sim_now);
528 entry.shown_at_real = Some(real_now);
529 entry.promoted_by_focus = by_focus;
530 if let Some(sink) = entry.shown_at_sink.as_ref() {
531 sink.set(Some(real_now));
532 }
533 }
534 }
535
536 let mut to_mark_paint: Vec<WidgetId> = Vec::new();
550 let mut to_promote: Vec<WidgetId> = Vec::new();
551 for entry in &self.tooltips {
552 let Some(sticky_after) = entry.sticky_after else {
553 continue;
554 };
555 if entry.overlay_id.is_none() || entry.is_sticky {
556 continue;
557 }
558 let elapsed = entry
559 .shown_at_real
560 .map(|t| real_now.saturating_duration_since(t));
561 let elapsed = match elapsed {
562 Some(e) => e,
563 None => continue,
564 };
565 to_mark_paint.push(entry.content_id);
569 if elapsed >= sticky_after {
570 to_promote.push(entry.content_id);
571 }
572 }
573 for id in to_mark_paint {
574 self.arena.mark_subtree_needs_paint(id);
575 }
576 for content_id in to_promote {
577 self.promote_tooltip_to_sticky(content_id);
578 }
579 }
580
581 pub(super) fn process_delayed_overlays(&mut self) {
582 let sim_now = self.sim_clock;
583 let mut noop = crate::window::NoopWindowOps;
584 self.process_delayed_overlays_impl(
585 |p| sim_now.saturating_duration_since(p.sim_requested_at),
586 &mut noop,
587 );
588 }
589
590 pub(super) fn process_delayed_overlays_real(&mut self, ops: &mut dyn crate::window::WindowOps) {
591 let real_now = std::time::Instant::now();
592 self.process_delayed_overlays_impl(
593 |p| real_now.saturating_duration_since(p.real_requested_at),
594 &mut *ops,
595 );
596 }
597
598 fn process_delayed_overlays_impl(
599 &mut self,
600 elapsed_fn: impl Fn(&PendingDelayedOverlay) -> std::time::Duration,
601 ops: &mut dyn crate::window::WindowOps,
602 ) {
603 let mut ready_indices = Vec::new();
604 for (index, pending) in self.pending_delayed_overlays.iter().enumerate() {
605 if elapsed_fn(pending) >= pending.delay {
606 ready_indices.push(index);
607 }
608 }
609
610 let mut ready = Vec::new();
611 for &index in ready_indices.iter().rev() {
612 ready.push(self.pending_delayed_overlays.remove(index));
613 }
614 ready.reverse();
615
616 let any_shown = !ready.is_empty();
617 for pending in ready {
618 let content_id = pending.request.content_id;
619 self.arena.activate(content_id);
620 let current_focus = self.focused;
621 self.overlay_manager.show(pending.request);
622 if let Some(focus_id) = current_focus {
623 self.overlay_manager.set_top_focus_restore(focus_id);
624 }
625 self.arena.mark_needs_paint(content_id);
626 if let Some(focus_target) = pending.focus_target
627 && self.arena.is_active(focus_target)
628 {
629 self.focus_ops(focus_target, &mut *ops);
630 }
631 }
632 if any_shown {
633 self.a11y_dirty = true;
634 }
635 }
636
637 pub(super) fn overlay_ancestor_for_widget(
638 &self,
639 widget_id: WidgetId,
640 ) -> Option<crate::overlay::OverlayId> {
641 self.overlay_manager
642 .stack
643 .iter()
644 .rev()
645 .find(|overlay| self.is_descendant_of(widget_id, overlay.content_id))
646 .map(|overlay| overlay.id)
647 }
648
649 pub(super) fn modal_overlay_for_widget(
650 &self,
651 widget_id: WidgetId,
652 ) -> Option<crate::overlay::OverlayId> {
653 let mut current = self.overlay_ancestor_for_widget(widget_id);
654
655 while let Some(overlay_id) = current {
656 let overlay = self.overlay_manager.overlay(overlay_id)?;
657 if matches!(
658 overlay.placement,
659 crate::overlay::OverlayPlacement::Centered
660 ) {
661 return Some(overlay_id);
662 }
663 current = overlay.parent_overlay;
664 }
665
666 None
667 }
668
669 fn menu_ancestor_for_widget(&self, widget_id: WidgetId) -> Option<WidgetId> {
670 let mut current = Some(widget_id);
671 while let Some(id) = current {
672 if let Some(node) = self.arena.get(id) {
673 let mut builder = AccessNodeBuilder::new();
674 node.widget.accessibility(&mut builder);
675 if builder.role() == accesskit::Role::Menu {
676 return Some(id);
677 }
678 }
679 current = self.arena.parent(id);
680 }
681 None
682 }
683
684 pub(super) fn dismiss_child_overlays_for_source(
685 &mut self,
686 source_widget: WidgetId,
687 preserve_content: Option<WidgetId>,
688 ops: &mut dyn crate::window::WindowOps,
689 ) {
690 if let Some(parent_overlay) = self.overlay_ancestor_for_widget(source_widget) {
691 let preserve_overlay = preserve_content
692 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
693 let (dismissed, focus_restore) = self
694 .overlay_manager
695 .dismiss_descendants_of(parent_overlay, preserve_overlay);
696 self.dormant_dismissed_content(&dismissed, &mut *ops);
697 if let Some(restore_id) = focus_restore
698 && self.arena.is_active(restore_id)
699 {
700 self.focus_ops(restore_id, &mut *ops);
701 }
702 return;
703 }
704
705 let Some(menu_root) = self.menu_ancestor_for_widget(source_widget) else {
706 return;
707 };
708 let preserve_overlay = preserve_content
709 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
710 let overlay_ids: Vec<crate::overlay::OverlayId> = self
711 .overlay_manager
712 .stack
713 .iter()
714 .filter(|overlay| {
715 overlay.parent_overlay.is_none()
716 && self.is_descendant_of(overlay.anchor, menu_root)
717 && !preserve_overlay.is_some_and(|keep| {
718 overlay.id == keep
719 || self.overlay_manager.is_descendant_of(overlay.id, keep)
720 })
721 })
722 .map(|overlay| overlay.id)
723 .collect();
724
725 for overlay_id in overlay_ids {
726 let (dismissed, focus_restore) =
727 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
728 self.dormant_dismissed_content(&dismissed, &mut *ops);
729 if let Some(restore_id) = focus_restore
730 && self.arena.is_active(restore_id)
731 {
732 self.focus_ops(restore_id, &mut *ops);
733 }
734 }
735 }
736
737 pub(super) fn dismiss_self_overlay_chain_for_source(
749 &mut self,
750 source_widget: WidgetId,
751 ops: &mut dyn crate::window::WindowOps,
752 ) {
753 let Some(start) = self.overlay_ancestor_for_widget(source_widget) else {
754 return;
755 };
756
757 let mut topmost_to_dismiss: Option<crate::overlay::OverlayId> = None;
758 let mut current = Some(start);
759 while let Some(overlay_id) = current {
760 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
761 break;
762 };
763 if self.overlay_is_host_surface(overlay_id) {
764 break;
765 }
766 topmost_to_dismiss = Some(overlay_id);
767 current = overlay.parent_overlay;
768 }
769
770 if let Some(target) = topmost_to_dismiss {
771 let (dismissed, focus_restore) =
772 self.overlay_manager.dismiss_with_focus_restore(target);
773 self.dormant_dismissed_content(&dismissed, &mut *ops);
774 if let Some(restore_id) = focus_restore
775 && self.arena.is_active(restore_id)
776 {
777 self.focus_ops(restore_id, &mut *ops);
778 }
779 }
780 }
781
782 fn overlay_is_anchor_positioned(placement: &crate::overlay::OverlayPlacement) -> bool {
799 use crate::overlay::OverlayPlacement as P;
800 match placement {
801 P::Below
802 | P::Above
803 | P::TrailingEdge
804 | P::AtPointer(_)
805 | P::NearAnchor { .. }
806 | P::BelowPreferred => true,
807 P::Centered | P::BottomCenter | P::ViewportCorner { .. } | P::FullViewport => false,
808 }
809 }
810
811 fn overlay_follows_focus_out(&self, overlay_id: crate::overlay::OverlayId) -> bool {
846 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
847 return false;
848 };
849 if !Self::overlay_is_anchor_positioned(&overlay.placement) {
850 return false;
851 }
852 if overlay.is_dismissing() {
853 return false;
854 }
855 !self
856 .tooltips
857 .iter()
858 .any(|entry| entry.content_id == overlay.content_id)
859 }
860
861 fn overlay_orbit_for_widget(&self, widget_id: WidgetId) -> Option<crate::overlay::OverlayId> {
884 self.overlay_manager
885 .stack
886 .iter()
887 .rev()
888 .find(|overlay| {
889 self.is_descendant_of(widget_id, overlay.content_id)
890 || (self.overlay_follows_focus_out(overlay.id)
891 && self.is_descendant_of(widget_id, overlay.anchor))
892 })
893 .map(|overlay| overlay.id)
894 }
895
896 pub(super) fn dismiss_overlays_left_by_focus(
923 &mut self,
924 old: Option<WidgetId>,
925 new_focus: WidgetId,
926 ops: &mut dyn crate::window::WindowOps,
927 ) {
928 let Some(old) = old else {
929 return;
930 };
931 let Some(old_overlay) = self.overlay_orbit_for_widget(old) else {
932 return;
933 };
934 let new_overlay = self.overlay_ancestor_for_widget(new_focus);
940
941 if let Some(new_overlay) = new_overlay
944 && (new_overlay == old_overlay
945 || self
946 .overlay_manager
947 .is_descendant_of(new_overlay, old_overlay))
948 {
949 return;
950 }
951
952 if !self.overlay_follows_focus_out(old_overlay) {
953 return;
954 }
955
956 let mut topmost = old_overlay;
957 let mut current = self
958 .overlay_manager
959 .overlay(old_overlay)
960 .and_then(|overlay| overlay.parent_overlay);
961 while let Some(overlay_id) = current {
962 if Some(overlay_id) == new_overlay {
966 break;
967 }
968 if self.overlay_is_host_surface(overlay_id) {
971 break;
972 }
973 if !self.overlay_follows_focus_out(overlay_id) {
974 break;
975 }
976 topmost = overlay_id;
977 current = self
978 .overlay_manager
979 .overlay(overlay_id)
980 .and_then(|overlay| overlay.parent_overlay);
981 }
982
983 let dismissed = self.overlay_manager.dismiss(topmost);
984 self.dormant_dismissed_content(&dismissed, &mut *ops);
985 }
986
987 pub(super) fn dismiss_all_overlays_except_hosts(
998 &mut self,
999 ops: &mut dyn crate::window::WindowOps,
1000 ) {
1001 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1002 .overlay_manager
1003 .stack
1004 .iter()
1005 .map(|overlay| overlay.id)
1006 .filter(|&id| !self.overlay_is_host_surface(id))
1007 .collect();
1008
1009 for overlay_id in to_dismiss {
1010 let dismissed = self.overlay_manager.dismiss(overlay_id);
1011 self.dormant_dismissed_content(&dismissed, &mut *ops);
1012 }
1013 }
1014
1015 pub(super) fn overlay_is_host_surface(&self, overlay_id: crate::overlay::OverlayId) -> bool {
1021 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
1022 return false;
1023 };
1024 if matches!(
1033 overlay.placement,
1034 crate::overlay::OverlayPlacement::Centered
1035 ) {
1036 return true;
1037 }
1038 let Some(node) = self.arena.get(overlay.content_id) else {
1039 return false;
1040 };
1041 let role = node
1049 .access_overrides
1050 .as_ref()
1051 .and_then(|o| o.role)
1052 .unwrap_or_else(|| {
1053 let mut builder = AccessNodeBuilder::new();
1054 node.widget.accessibility(&mut builder);
1055 builder.role()
1056 });
1057 matches!(
1058 role,
1059 accesskit::Role::Tooltip
1060 | accesskit::Role::Dialog
1061 | accesskit::Role::AlertDialog
1062 | accesskit::Role::MenuBar
1063 )
1064 }
1065
1066 pub(super) fn dismiss_modal_for_source(
1067 &mut self,
1068 source_widget: WidgetId,
1069 ops: &mut dyn crate::window::WindowOps,
1070 ) -> bool {
1071 let Some(modal_overlay) = self.modal_overlay_for_widget(source_widget) else {
1072 return false;
1073 };
1074
1075 let (dismissed, focus_restore) = self
1076 .overlay_manager
1077 .dismiss_with_focus_restore(modal_overlay);
1078 self.dormant_dismissed_content(&dismissed, &mut *ops);
1079 if let Some(restore_id) = focus_restore
1080 && self.arena.is_active(restore_id)
1081 {
1082 self.focus_ops(restore_id, &mut *ops);
1083 }
1084 true
1085 }
1086
1087 pub fn is_descendant_of(&self, widget_id: WidgetId, ancestor: WidgetId) -> bool {
1088 if widget_id == ancestor {
1089 return true;
1090 }
1091 let mut current = self.arena.parent(widget_id);
1092 while let Some(parent_id) = current {
1093 if parent_id == ancestor {
1094 return true;
1095 }
1096 current = self.arena.parent(parent_id);
1097 }
1098 false
1099 }
1100
1101 fn is_overlay_content_root(&self, widget_id: WidgetId) -> bool {
1108 self.overlay_manager.find_by_content(widget_id).is_some()
1109 }
1110
1111 pub(super) fn tooltip_focus_enter(&mut self, widget_id: WidgetId) {
1140 type ToShow = (WidgetId, WidgetId, crate::overlay::TooltipPlacement);
1163 let mut direct: Vec<ToShow> = Vec::new();
1164 let mut reverse: Vec<ToShow> = Vec::new();
1165 for e in &self.tooltips {
1166 if e.sticky_after.is_none() || e.overlay_id.is_some() || e.suppressed_until_focus_leaves
1167 {
1168 continue;
1169 }
1170 if self.is_descendant_of(widget_id, e.anchor_id) {
1171 direct.push((e.anchor_id, e.content_id, e.placement));
1172 } else if self.is_descendant_of(e.anchor_id, widget_id) {
1173 reverse.push((e.anchor_id, e.content_id, e.placement));
1174 }
1175 }
1176 let mut to_show = direct;
1177 if reverse.len() == 1 && !self.is_overlay_content_root(widget_id) {
1181 to_show.extend(reverse);
1182 }
1183
1184 let sim_now = self.sim_clock;
1193 let real_now = std::time::Instant::now();
1194 for (_anchor_id, content_id, _placement) in to_show {
1195 if let Some(entry) = self
1196 .tooltips
1197 .iter_mut()
1198 .find(|e| e.content_id == content_id)
1199 {
1200 entry.hover_start = Some(sim_now);
1201 entry.real_hover_start = Some(real_now);
1202 entry.hover_origin = None;
1203 entry.armed_by_focus = true;
1204 }
1205 }
1206 }
1207
1208 pub(super) fn show_highlight_tooltip(
1218 &mut self,
1219 item_id: WidgetId,
1220 ops: &mut dyn crate::window::WindowOps,
1221 ) {
1222 self.clear_highlight_tooltip(&mut *ops);
1225
1226 let found = self.tooltips.iter().find_map(|e| {
1230 if e.overlay_id.is_none() && self.is_descendant_of(e.anchor_id, item_id) {
1231 Some((e.anchor_id, e.content_id, e.placement))
1232 } else {
1233 None
1234 }
1235 });
1236 let Some((anchor_id, content_id, placement)) = found else {
1237 return;
1238 };
1239
1240 let parent_overlay = self.overlay_ancestor_for_widget(item_id);
1241 self.arena.activate(content_id);
1242 let fade_duration = if self.prefers_reduced_motion {
1243 None
1244 } else {
1245 Some(self.theme.motion.duration_fast)
1246 };
1247 let oid = self.show_overlay(crate::overlay::OverlayRequest {
1248 content_id,
1249 anchor: anchor_id,
1250 placement: Self::tooltip_overlay_placement(placement),
1251 dismiss: crate::overlay::DismissBehavior::Manual,
1252 layer: crate::overlay::OverlayLayer::InTree,
1253 parent_overlay,
1254 on_dismiss: None,
1255 fade_duration,
1256 });
1257 let real_now = std::time::Instant::now();
1258 let sim_now = self.sim_clock;
1259 if let Some(entry) = self
1260 .tooltips
1261 .iter_mut()
1262 .find(|e| e.content_id == content_id)
1263 {
1264 entry.overlay_id = Some(oid);
1265 entry.shown_at_sim = Some(sim_now);
1266 entry.shown_at_real = Some(real_now);
1267 if let Some(sink) = entry.shown_at_sink.as_ref() {
1268 sink.set(Some(real_now));
1269 }
1270 }
1271 self.highlight_tooltip = Some((oid, content_id));
1272 }
1273
1274 pub(super) fn clear_highlight_tooltip(&mut self, ops: &mut dyn crate::window::WindowOps) {
1279 let Some((oid, content_id)) = self.highlight_tooltip.take() else {
1280 return;
1281 };
1282 if self.overlay_manager.stack.iter().any(|o| o.id == oid) {
1283 let dismissed = self.overlay_manager.dismiss(oid);
1284 self.dormant_dismissed_content(&dismissed, &mut *ops);
1285 }
1286 if let Some(entry) = self
1287 .tooltips
1288 .iter_mut()
1289 .find(|e| e.content_id == content_id)
1290 {
1291 entry.overlay_id = None;
1292 entry.shown_at_sim = None;
1293 entry.shown_at_real = None;
1294 if let Some(sink) = entry.shown_at_sink.as_ref() {
1295 sink.set(None);
1296 }
1297 }
1298 }
1299
1300 pub(super) fn tooltip_focus_leave_outside(
1311 &mut self,
1312 new_focus: Option<WidgetId>,
1313 ops: &mut dyn crate::window::WindowOps,
1314 ) {
1315 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1316 .tooltips
1317 .iter()
1318 .filter(|e| e.promoted_by_focus && e.overlay_id.is_some())
1319 .filter(|e| {
1320 let in_scope = new_focus
1321 .map(|nf| {
1322 self.is_descendant_of(nf, e.anchor_id)
1327 || self.is_descendant_of(e.anchor_id, nf)
1328 || self.is_descendant_of(nf, e.content_id)
1329 })
1330 .unwrap_or(false);
1331 !in_scope
1332 })
1333 .filter_map(|e| e.overlay_id)
1334 .collect();
1335
1336 for oid in to_dismiss {
1337 let dismissed = self.overlay_manager.dismiss(oid);
1338 self.dormant_dismissed_content(&dismissed, &mut *ops);
1339 }
1340
1341 for e in &mut self.tooltips {
1344 if e.armed_by_focus && e.overlay_id.is_none() {
1345 e.hover_start = None;
1346 e.real_hover_start = None;
1347 e.armed_by_focus = false;
1348 }
1349 }
1350
1351 let served: Vec<usize> = self
1355 .tooltips
1356 .iter()
1357 .enumerate()
1358 .filter(|(_, e)| e.suppressed_until_focus_leaves)
1359 .filter(|(_, e)| {
1360 let still_inside = new_focus
1361 .map(|nf| {
1362 self.is_descendant_of(nf, e.anchor_id)
1363 || self.is_descendant_of(nf, e.content_id)
1364 })
1365 .unwrap_or(false);
1366 !still_inside
1367 })
1368 .map(|(i, _)| i)
1369 .collect();
1370 for i in served {
1371 self.tooltips[i].suppressed_until_focus_leaves = false;
1372 }
1373 }
1374
1375 fn tooltip_hover_targets_anchor(&self, widget_id: WidgetId, anchor_id: WidgetId) -> bool {
1395 if !self.is_descendant_of(widget_id, anchor_id) {
1396 return false;
1397 }
1398 if let Some(overlay_id) = self.overlay_ancestor_for_widget(widget_id)
1399 && let Some(content_id) = self
1400 .overlay_manager
1401 .overlay(overlay_id)
1402 .map(|o| o.content_id)
1403 && !self.is_descendant_of(anchor_id, content_id)
1404 {
1405 return false;
1406 }
1407 true
1408 }
1409
1410 pub(super) fn tooltip_pointer_enter(&mut self, widget_id: WidgetId) {
1422 let innermost: Option<usize> = self
1423 .tooltips
1424 .iter()
1425 .enumerate()
1426 .filter(|(_, entry)| self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id))
1427 .filter_map(|(index, entry)| {
1428 self.ancestor_distance(widget_id, entry.anchor_id)
1429 .map(|depth| (depth, index))
1430 })
1431 .min()
1432 .map(|(_, index)| index);
1433 let Some(index) = innermost else {
1434 return;
1435 };
1436 if self.tooltips[index].overlay_id.is_some() {
1438 return;
1439 }
1440 self.tooltips[index].hover_start = Some(self.sim_clock);
1441 self.tooltips[index].real_hover_start = Some(std::time::Instant::now());
1442 self.tooltips[index].hover_origin = self.last_pointer_position;
1443 self.tooltips[index].armed_by_focus = false;
1444 self.arena.mark_needs_paint(self.tooltips[index].anchor_id);
1445 }
1446
1447 fn ancestor_distance(&self, widget_id: WidgetId, ancestor: WidgetId) -> Option<usize> {
1450 let mut current = widget_id;
1451 let mut depth = 0usize;
1452 loop {
1453 if current == ancestor {
1454 return Some(depth);
1455 }
1456 current = self.arena.parent(current)?;
1457 depth += 1;
1458 }
1459 }
1460
1461 pub(super) fn tooltip_pointer_press(&mut self, at: Option<teksilo_canvas::Point>) {
1476 let mut to_dismiss = Vec::new();
1477 let candidates: Vec<(usize, Option<crate::overlay::OverlayId>)> = self
1478 .tooltips
1479 .iter()
1480 .enumerate()
1481 .map(|(i, e)| (i, if e.is_sticky { None } else { e.overlay_id }))
1482 .collect();
1483 for (index, overlay_id) in candidates {
1484 let inside = match (overlay_id, at) {
1489 (Some(oid), Some(pos)) => self
1490 .overlay_content_bounds(oid)
1491 .is_some_and(|rect| rect.contains(pos)),
1492 _ => false,
1493 };
1494 if inside {
1495 continue;
1496 }
1497 let entry = &mut self.tooltips[index];
1498 entry.hover_start = None;
1499 entry.real_hover_start = None;
1500 entry.hover_origin = None;
1501 if let Some(oid) = overlay_id {
1502 to_dismiss.push(oid);
1503 }
1504 }
1505 for overlay_id in to_dismiss {
1506 self.dismiss_overlay(overlay_id);
1507 }
1508 }
1509
1510 pub(super) fn tooltip_escape_pressed(&mut self) {
1532 self.tooltip_pointer_press(None);
1535 }
1536
1537 pub(super) fn tooltip_window_deactivated(&mut self) {
1544 self.tooltip_pointer_press(None);
1547 }
1548
1549 pub(crate) fn tooltip_cancel_pending_dwell(&mut self) {
1557 for entry in &mut self.tooltips {
1558 if entry.overlay_id.is_none() {
1559 entry.hover_start = None;
1560 entry.real_hover_start = None;
1561 entry.hover_origin = None;
1562 }
1563 }
1564 }
1565
1566 pub(super) fn tooltip_pointer_moved(
1570 &mut self,
1571 widget_id: WidgetId,
1572 position: teksilo_canvas::Point,
1573 ) {
1574 let matching: Vec<usize> = self
1575 .tooltips
1576 .iter()
1577 .enumerate()
1578 .filter(|(_, entry)| {
1579 entry.overlay_id.is_none()
1580 && entry.hover_start.is_some()
1581 && !entry.armed_by_focus
1582 && self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id)
1583 })
1584 .map(|(index, _)| index)
1585 .collect();
1586 if matching.is_empty() {
1587 return;
1588 }
1589 let now = self.sim_clock;
1590 let real_now = std::time::Instant::now();
1591 let slop = super::TOOLTIP_STATIONARY_SLOP;
1592 let slop_sq = slop * slop;
1593 for index in matching {
1594 let origin = match self.tooltips[index].hover_origin {
1595 Some(o) => o,
1596 None => {
1597 self.tooltips[index].hover_origin = Some(position);
1601 continue;
1602 }
1603 };
1604 let dx = position.x - origin.x;
1605 let dy = position.y - origin.y;
1606 if dx * dx + dy * dy <= slop_sq {
1607 continue;
1608 }
1609 self.tooltips[index].hover_start = Some(now);
1611 self.tooltips[index].real_hover_start = Some(real_now);
1612 self.tooltips[index].hover_origin = Some(position);
1613 }
1614 }
1615
1616 pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
1633 let Some(entry) = self
1634 .tooltips
1635 .iter_mut()
1636 .find(|entry| entry.content_id == content_id)
1637 else {
1638 return;
1639 };
1640 if entry.is_sticky {
1641 return;
1642 }
1643 entry.is_sticky = true;
1644 let overlay_id = entry.overlay_id;
1645 if let Some(overlay_id) = overlay_id {
1646 self.overlay_manager.set_dismiss(
1647 overlay_id,
1648 crate::overlay::DismissBehavior::EscapeOrClickOutside,
1649 );
1650 }
1651 }
1652
1653 pub(super) fn tooltip_pointer_leave(
1654 &mut self,
1655 widget_id: WidgetId,
1656 _ops: &mut dyn crate::window::WindowOps,
1657 ) {
1658 let matching: Vec<usize> = self
1659 .tooltips
1660 .iter()
1661 .enumerate()
1662 .filter(|(_, entry)| self.is_descendant_of(widget_id, entry.anchor_id))
1663 .map(|(index, _)| index)
1664 .collect();
1665 for index in matching {
1666 self.tooltips[index].hover_start = None;
1669 self.tooltips[index].real_hover_start = None;
1670 self.tooltips[index].hover_origin = None;
1671 }
1683 }
1684
1685 pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
1687 let now = std::time::Instant::now();
1688 let session_active = self.tooltip_session_active_real(now);
1689 let tooltip_deadline = self
1690 .tooltips
1691 .iter()
1692 .filter(|entry| entry.overlay_id.is_none())
1693 .filter_map(|entry| {
1694 let start = entry.real_hover_start?;
1695 let delay = self.effective_tooltip_delay(entry.delay, session_active);
1696 Some(start + delay)
1697 })
1698 .min();
1699
1700 let ref_time = self.last_frame_time.unwrap_or_else(std::time::Instant::now);
1721 let dwell_tooltip_deadline = self
1722 .tooltips
1723 .iter()
1724 .filter_map(|entry| {
1725 let sticky_after = entry.sticky_after?;
1726 let shown_at = entry.shown_at_real?;
1727 if entry.overlay_id.is_none() || entry.is_sticky {
1728 return None;
1729 }
1730 let dwell_step = sticky_after / super::TOOLTIP_DWELL_STEPS;
1731 if dwell_step.is_zero() {
1732 return None;
1733 }
1734 let elapsed = ref_time.saturating_duration_since(shown_at);
1735 if elapsed >= sticky_after {
1736 return None;
1737 }
1738 let steps_passed = (elapsed.as_millis() / dwell_step.as_millis()) as u32;
1741 let next_step_at = shown_at + dwell_step * (steps_passed + 1);
1742 Some(next_step_at.min(shown_at + sticky_after))
1743 })
1744 .min();
1745 let delayed_overlay_deadline = self
1746 .pending_delayed_overlays
1747 .iter()
1748 .map(|pending| pending.real_requested_at + pending.delay)
1749 .min();
1750 let auto_dismiss_deadline = self.overlay_manager.next_auto_dismiss_deadline();
1751 let pointer_leave_deadline = self.overlay_manager.next_pointer_leave_deadline();
1758 let animation_deadline = self
1759 .animation_scheduler
1760 .next_deadline(&self.arena, self.paint_epoch);
1761 let animated_quad_deadline = self
1766 .animated_quads
1767 .next_deadline(&self.arena, self.paint_epoch);
1768 let gesture_deadline = self.next_gesture_deadline();
1769 let wake_at_deadline = self.pending_wake_at.get();
1770 let frame_tick_deadline = self.frame_tick_deadline();
1776
1777 [
1778 tooltip_deadline,
1779 dwell_tooltip_deadline,
1780 delayed_overlay_deadline,
1781 auto_dismiss_deadline,
1782 pointer_leave_deadline,
1783 animation_deadline,
1784 animated_quad_deadline,
1785 gesture_deadline,
1786 wake_at_deadline,
1787 frame_tick_deadline,
1788 ]
1789 .into_iter()
1790 .flatten()
1791 .min()
1792 }
1793
1794 pub fn overlay_manager(&self) -> &crate::overlay::OverlayManager {
1795 &self.overlay_manager
1796 }
1797
1798 pub fn overlay_manager_mut(&mut self) -> &mut crate::overlay::OverlayManager {
1803 &mut self.overlay_manager
1804 }
1805
1806 pub fn active_overlays(&self) -> Vec<crate::overlay::OverlayId> {
1807 self.overlay_manager.active_ids()
1808 }
1809
1810 pub fn overlay_content_bounds(
1819 &self,
1820 id: crate::overlay::OverlayId,
1821 ) -> Option<teksilo_canvas::Rect> {
1822 self.overlay_manager
1823 .stack
1824 .iter()
1825 .find(|o| o.id == id)
1826 .map(|o| o.bounds)
1827 }
1828
1829 pub fn show_overlay(
1830 &mut self,
1831 request: crate::overlay::OverlayRequest,
1832 ) -> crate::overlay::OverlayId {
1833 let fade_duration = request.fade_duration;
1834 let content_id = request.content_id;
1835 let id = self.overlay_manager.show(request);
1836 self.a11y_dirty = true;
1844 if let Some(duration) = fade_duration {
1845 self.attach_overlay_fade(id, content_id, duration);
1846 }
1847 id
1848 }
1849
1850 pub fn show_overlay_from_source(
1853 &mut self,
1854 source_widget: WidgetId,
1855 mut request: crate::overlay::OverlayRequest,
1856 ) -> crate::overlay::OverlayId {
1857 if request.parent_overlay.is_none() {
1858 request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1859 }
1860 if let Some(existing) = self.overlay_manager.find_by_content(request.content_id) {
1861 return existing;
1862 }
1863
1864 let fade_duration = request.fade_duration;
1865 let content_id = request.content_id;
1866 let current_focus = self.focused;
1867 let id = self.overlay_manager.show(request);
1868 self.a11y_dirty = true;
1869 if let Some(focus_id) = current_focus {
1870 self.overlay_manager.set_top_focus_restore(focus_id);
1871 }
1872 if let Some(duration) = fade_duration {
1873 self.attach_overlay_fade(id, content_id, duration);
1874 }
1875 id
1876 }
1877
1878 pub fn show_overlay_for(
1879 &mut self,
1880 request: crate::overlay::OverlayRequest,
1881 duration: std::time::Duration,
1882 ) -> crate::overlay::OverlayId {
1883 let fade_duration = request.fade_duration;
1884 let content_id = request.content_id;
1885 let id = self.overlay_manager.show_for(request, duration);
1886 self.overlay_manager.set_shown_at_sim(id, self.sim_clock);
1887 self.a11y_dirty = true;
1888 if let Some(fade) = fade_duration {
1889 self.attach_overlay_fade(id, content_id, fade);
1890 }
1891 id
1892 }
1893
1894 fn attach_overlay_fade(
1902 &mut self,
1903 overlay_id: crate::overlay::OverlayId,
1904 content_id: WidgetId,
1905 duration: std::time::Duration,
1906 ) {
1907 let opacity = crate::signal::Signal::<f32>::new_animated(0.0);
1908 self.register_animated_signal(&opacity, content_id);
1909 self.set_opacity(content_id, opacity.clone());
1910 if self.prefers_reduced_motion() {
1914 opacity.set(1.0);
1915 self.overlay_manager
1916 .attach_fade(overlay_id, opacity, std::time::Duration::ZERO);
1917 return;
1918 }
1919 let _ = opacity.try_animate_with_options(crate::animation::AnimationRequest {
1920 target: 1.0,
1921 duration,
1922 easing: teksilo_tokens::Easing::EaseOut,
1923 frame_interval: None,
1924 looping: false,
1925 epsilon: 0.0,
1926 max_duration: None,
1927 });
1928 self.overlay_manager
1929 .attach_fade(overlay_id, opacity, duration);
1930 }
1931
1932 pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
1937 let mut noop = crate::window::NoopWindowOps;
1938 self.dismiss_overlay_with_ops(id, &mut noop);
1939 }
1940
1941 pub fn dismiss_overlay_with_ops(
1944 &mut self,
1945 id: crate::overlay::OverlayId,
1946 ops: &mut dyn crate::window::WindowOps,
1947 ) {
1948 let dismissed = self.overlay_manager.dismiss(id);
1949 self.dormant_dismissed_content(&dismissed, &mut *ops);
1950 }
1951
1952 pub(super) fn dormant_dismissed_content(
1953 &mut self,
1954 content_ids: &[WidgetId],
1955 ops: &mut dyn crate::window::WindowOps,
1956 ) {
1957 let mut any_tooltip_dismissed = false;
1963 for &id in content_ids {
1964 if let Some(entry) = self.tooltips.iter_mut().find(|e| e.content_id == id) {
1965 entry.suppressed_until_focus_leaves = entry.promoted_by_focus;
1972 entry.overlay_id = None;
1973 entry.is_sticky = false;
1974 entry.hover_start = None;
1975 entry.real_hover_start = None;
1976 entry.hover_origin = None;
1977 entry.shown_at_sim = None;
1978 entry.shown_at_real = None;
1979 entry.promoted_by_focus = false;
1980 entry.armed_by_focus = false;
1981 if let Some(sink) = entry.shown_at_sink.as_ref() {
1982 sink.set(None);
1983 }
1984 any_tooltip_dismissed = true;
1985 }
1986 }
1987 if any_tooltip_dismissed {
1988 let grace = super::TOOLTIP_SESSION_GRACE;
1989 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
1990 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
1991 }
1992 for &id in content_ids {
1993 let focused_in_subtree = self
1994 .focused
1995 .filter(|focused| self.is_descendant_of(*focused, id));
1996 let hovered_in_subtree = self
1997 .hovered
1998 .filter(|hovered| self.is_descendant_of(*hovered, id));
1999
2000 if let Some(focused) = focused_in_subtree {
2001 self.dispatch_to_widget(focused, &WidgetEvent::FocusLost, &mut *ops);
2002 if self
2003 .focused
2004 .is_some_and(|current| self.is_descendant_of(current, id))
2005 {
2006 let old = self.focused;
2007 self.set_focused(None);
2008 self.focus_origin = None;
2009 self.update_focus_within_signals(old, None);
2010 self.update_view_focus_signals(old, None);
2011 }
2012 }
2013
2014 self.arena.set_dormant(id);
2015
2016 if hovered_in_subtree.is_some() {
2017 let old = self.hovered;
2018 self.set_hovered(None);
2019 self.update_hover_within_signals(old, None);
2020 }
2021 }
2022 if !content_ids.is_empty() {
2023 self.cached_frame = None;
2024 self.a11y_dirty = true;
2025 }
2026 }
2027
2028 pub fn is_visible(&self, id: WidgetId) -> bool {
2029 self.arena.is_active(id)
2030 }
2031}
2032
2033#[cfg(test)]
2034mod tests {
2035 use super::*;
2036 use crate::test_widgets::FillWidget;
2037
2038 #[test]
2039 fn is_visible_reflects_dormancy() {
2040 let mut tree = WidgetTree::new();
2041 let widget = tree.add(FillWidget::new());
2042 tree.layout(SizeProposal::exact(100.0, 50.0));
2043
2044 assert!(tree.is_visible(widget));
2045 tree.set_dormant(widget);
2046 assert!(!tree.is_visible(widget));
2047 tree.activate(widget);
2048 assert!(tree.is_visible(widget));
2049 }
2050
2051 #[test]
2052 fn frame_tick_deadline_caps_per_frame_effects_at_60hz() {
2053 let mut tree = WidgetTree::new();
2054 assert!(tree.frame_tick_deadline().is_none());
2056
2057 tree.request_frame();
2060 let t0 = std::time::Instant::now();
2062 tree.last_frame_time = Some(t0);
2063
2064 let deadline = tree
2065 .frame_tick_deadline()
2066 .expect("an armed per-frame effect must publish a deadline");
2067 let interval = deadline.saturating_duration_since(t0);
2068 assert!(
2071 interval >= std::time::Duration::from_micros(16_000)
2072 && interval <= std::time::Duration::from_micros(17_500),
2073 "per-frame effects must pace at ~60 Hz (got {interval:?})"
2074 );
2075
2076 assert_eq!(
2079 tree.next_timer_deadline(),
2080 Some(deadline),
2081 "frame-tick deadline must be surfaced by next_timer_deadline"
2082 );
2083 }
2084
2085 #[test]
2086 fn throttled_subscriber_stretches_deadline_but_per_frame_wins_min() {
2087 use crate::test_widgets::FillWidget;
2088 let mut tree = WidgetTree::new();
2089 let w = tree.add(FillWidget::new());
2090 let _throttled =
2092 tree.subscribe_frame_tick_throttled(w, std::time::Duration::from_millis(1500));
2093 tree.request_frame();
2094 let t0 = std::time::Instant::now();
2095 tree.last_frame_time = Some(t0);
2096
2097 let d = tree.frame_tick_deadline().expect("armed");
2100 let dt = d.saturating_duration_since(t0);
2101 assert!(
2102 dt >= std::time::Duration::from_millis(1490)
2103 && dt <= std::time::Duration::from_millis(1510),
2104 "a lone throttled subscriber must pace at its interval (~1.5 s), got {dt:?}"
2105 );
2106
2107 let w2 = tree.add(FillWidget::new());
2111 let _per_frame = tree.subscribe_frame_tick(w2);
2112 let d2 = tree.frame_tick_deadline().expect("armed");
2113 let dt2 = d2.saturating_duration_since(t0);
2114 assert!(
2115 dt2 <= std::time::Duration::from_millis(20),
2116 "a per-frame subscriber must pull the shared deadline to 60 Hz, got {dt2:?}"
2117 );
2118 }
2119
2120 #[test]
2121 fn show_and_dismiss_overlay() {
2122 let mut tree = WidgetTree::new();
2123 let anchor = tree.add(FillWidget::new());
2124 let content = tree.add(FillWidget::new().label("Overlay"));
2125 tree.layout(SizeProposal::exact(200.0, 100.0));
2126
2127 assert!(tree.active_overlays().is_empty());
2128
2129 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2130 content_id: content,
2131 anchor,
2132 placement: crate::overlay::OverlayPlacement::Below,
2133 dismiss: crate::overlay::DismissBehavior::Manual,
2134 layer: crate::overlay::OverlayLayer::InTree,
2135 parent_overlay: None,
2136 on_dismiss: None,
2137 fade_duration: None,
2138 });
2139
2140 assert_eq!(tree.active_overlays().len(), 1);
2141
2142 tree.dismiss_overlay(id);
2143 assert!(tree.active_overlays().is_empty());
2144 assert!(!tree.is_visible(content));
2145 }
2146
2147 #[test]
2152 fn modal_centered_overlay_is_a_host_surface() {
2153 let mut tree = WidgetTree::new();
2154 let anchor = tree.add(FillWidget::new());
2155 let modal_content = tree.add(FillWidget::new().label("Modal"));
2156 let dropdown_content = tree.add(FillWidget::new().label("Dropdown"));
2157 tree.layout(SizeProposal::exact(200.0, 100.0));
2158
2159 let modal = tree.show_overlay(crate::overlay::OverlayRequest {
2160 content_id: modal_content,
2161 anchor,
2162 placement: crate::overlay::OverlayPlacement::Centered,
2163 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2164 layer: crate::overlay::OverlayLayer::InTree,
2165 parent_overlay: None,
2166 on_dismiss: None,
2167 fade_duration: None,
2168 });
2169 let dropdown = tree.show_overlay(crate::overlay::OverlayRequest {
2170 content_id: dropdown_content,
2171 anchor,
2172 placement: crate::overlay::OverlayPlacement::Below,
2173 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2174 layer: crate::overlay::OverlayLayer::InTree,
2175 parent_overlay: Some(modal),
2176 on_dismiss: None,
2177 fade_duration: None,
2178 });
2179
2180 assert!(
2181 tree.overlay_is_host_surface(modal),
2182 "a Centered modal overlay must be treated as a host surface"
2183 );
2184 assert!(
2185 !tree.overlay_is_host_surface(dropdown),
2186 "a plain dropdown overlay is not a host surface"
2187 );
2188 }
2189
2190 #[test]
2191 fn escape_dismisses_topmost_overlay() {
2192 let mut tree = WidgetTree::new();
2193 let anchor = tree.add(FillWidget::new().focusable());
2194 let content = tree.add(FillWidget::new());
2195 tree.layout(SizeProposal::exact(200.0, 100.0));
2196 tree.focus(anchor);
2197
2198 tree.show_overlay(crate::overlay::OverlayRequest {
2199 content_id: content,
2200 anchor,
2201 placement: crate::overlay::OverlayPlacement::Below,
2202 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2203 layer: crate::overlay::OverlayLayer::InTree,
2204 parent_overlay: None,
2205 on_dismiss: None,
2206 fade_duration: None,
2207 });
2208
2209 assert_eq!(tree.active_overlays().len(), 1);
2210
2211 tree.press_key(Key::Escape, Modifiers::NONE);
2212 assert!(tree.active_overlays().is_empty());
2213 assert!(!tree.is_visible(content));
2214 }
2215
2216 fn show_two_nested_overlays(tree: &mut WidgetTree) -> (WidgetId, WidgetId) {
2219 let anchor = tree.add(FillWidget::new());
2220 let c1 = tree.add(FillWidget::new());
2221 let c2 = tree.add(FillWidget::new());
2222 tree.layout(SizeProposal::exact(200.0, 100.0));
2223 let o1 = tree.show_overlay(crate::overlay::OverlayRequest {
2224 content_id: c1,
2225 anchor,
2226 placement: crate::overlay::OverlayPlacement::Below,
2227 dismiss: crate::overlay::DismissBehavior::Manual,
2228 layer: crate::overlay::OverlayLayer::InTree,
2229 parent_overlay: None,
2230 on_dismiss: None,
2231 fade_duration: None,
2232 });
2233 tree.show_overlay(crate::overlay::OverlayRequest {
2234 content_id: c2,
2235 anchor: c1,
2236 placement: crate::overlay::OverlayPlacement::Below,
2237 dismiss: crate::overlay::DismissBehavior::Manual,
2238 layer: crate::overlay::OverlayLayer::InTree,
2239 parent_overlay: Some(o1),
2240 on_dismiss: None,
2241 fade_duration: None,
2242 });
2243 (c1, c2)
2244 }
2245
2246 #[test]
2247 fn nested_overlay_back_key_dismisses_with_arrow_left_under_ltr() {
2248 let mut tree = WidgetTree::new();
2249 let _ = show_two_nested_overlays(&mut tree);
2250 assert_eq!(tree.active_overlays().len(), 2);
2251
2252 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2254 assert_eq!(tree.active_overlays().len(), 2);
2255
2256 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2258 assert_eq!(tree.active_overlays().len(), 1);
2259 }
2260
2261 #[test]
2262 fn nested_overlay_back_key_flips_to_arrow_right_under_rtl() {
2263 let mut tree = WidgetTree::new();
2264 tree.set_layout_direction(crate::environment::LayoutDirection::RightToLeft);
2265 let _ = show_two_nested_overlays(&mut tree);
2266 assert_eq!(tree.active_overlays().len(), 2);
2267
2268 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2271 assert_eq!(tree.active_overlays().len(), 2);
2272
2273 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2275 assert_eq!(tree.active_overlays().len(), 1);
2276 }
2277
2278 #[test]
2283 fn back_key_does_not_dismiss_a_dialog_on_top_of_a_modal() {
2284 let mut tree = WidgetTree::new();
2285 let anchor = tree.add(FillWidget::new());
2286 let scrim1 = tree.add(FillWidget::new());
2287 let scrim2 = tree.add(FillWidget::new());
2288 let dialog = tree.add(FillWidget::new());
2289 tree.layout(SizeProposal::exact(200.0, 100.0));
2290 for c in [scrim1, scrim2] {
2292 tree.show_overlay(crate::overlay::OverlayRequest {
2293 content_id: c,
2294 anchor,
2295 placement: crate::overlay::OverlayPlacement::Below,
2296 dismiss: crate::overlay::DismissBehavior::Manual,
2297 layer: crate::overlay::OverlayLayer::InTree,
2298 parent_overlay: None,
2299 on_dismiss: None,
2300 fade_duration: None,
2301 });
2302 }
2303 tree.show_overlay(crate::overlay::OverlayRequest {
2305 content_id: dialog,
2306 anchor,
2307 placement: crate::overlay::OverlayPlacement::Centered,
2308 dismiss: crate::overlay::DismissBehavior::Manual,
2309 layer: crate::overlay::OverlayLayer::InTree,
2310 parent_overlay: None,
2311 on_dismiss: None,
2312 fade_duration: None,
2313 });
2314 assert_eq!(tree.active_overlays().len(), 3);
2315
2316 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2319 assert_eq!(
2320 tree.active_overlays().len(),
2321 3,
2322 "the back key must not close a dialog sitting on top of a modal"
2323 );
2324 }
2325
2326 #[test]
2327 fn escape_does_not_dismiss_manual_overlay() {
2328 let mut tree = WidgetTree::new();
2329 let anchor = tree.add(FillWidget::new().focusable());
2330 let content = tree.add(FillWidget::new());
2331 tree.layout(SizeProposal::exact(200.0, 100.0));
2332 tree.focus(anchor);
2333
2334 tree.show_overlay(crate::overlay::OverlayRequest {
2335 content_id: content,
2336 anchor,
2337 placement: crate::overlay::OverlayPlacement::Below,
2338 dismiss: crate::overlay::DismissBehavior::Manual,
2339 layer: crate::overlay::OverlayLayer::InTree,
2340 parent_overlay: None,
2341 on_dismiss: None,
2342 fade_duration: None,
2343 });
2344
2345 assert_eq!(tree.active_overlays().len(), 1);
2346
2347 tree.press_key(Key::Escape, Modifiers::NONE);
2348 assert_eq!(tree.active_overlays().len(), 1);
2350 }
2351
2352 #[test]
2353 fn click_outside_dismisses_overlay() {
2354 let mut tree = WidgetTree::new();
2355 let anchor = tree.add(FillWidget::new());
2356 let content = tree.add(FillWidget::new());
2357 tree.layout(SizeProposal::exact(200.0, 100.0));
2358
2359 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2360 content_id: content,
2361 anchor,
2362 placement: crate::overlay::OverlayPlacement::Below,
2363 dismiss: crate::overlay::DismissBehavior::ClickOutside,
2364 layer: crate::overlay::OverlayLayer::InTree,
2365 parent_overlay: None,
2366 on_dismiss: None,
2367 fade_duration: None,
2368 });
2369
2370 tree.overlay_manager
2371 .set_content_bounds(overlay, teksilo_canvas::Size::new(100.0, 50.0));
2372
2373 assert_eq!(tree.active_overlays().len(), 1);
2374
2375 tree.dispatch_event(WidgetEvent::PointerDown {
2376 position: Point::new(500.0, 500.0),
2377 button: PointerButton::Primary,
2378 modifiers: Modifiers::NONE,
2379 });
2380 assert!(tree.active_overlays().is_empty());
2381 assert!(!tree.is_visible(content));
2382 }
2383
2384 #[test]
2385 fn cascade_dismissal() {
2386 let mut tree = WidgetTree::new();
2387 let anchor = tree.add(FillWidget::new());
2388 let content_a = tree.add(FillWidget::new());
2389 let content_b = tree.add(FillWidget::new());
2390 tree.layout(SizeProposal::exact(200.0, 100.0));
2391
2392 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2393 content_id: content_a,
2394 anchor,
2395 placement: crate::overlay::OverlayPlacement::Below,
2396 dismiss: crate::overlay::DismissBehavior::Manual,
2397 layer: crate::overlay::OverlayLayer::InTree,
2398 parent_overlay: None,
2399 on_dismiss: None,
2400 fade_duration: None,
2401 });
2402 tree.show_overlay(crate::overlay::OverlayRequest {
2403 content_id: content_b,
2404 anchor: content_a,
2405 placement: crate::overlay::OverlayPlacement::TrailingEdge,
2406 dismiss: crate::overlay::DismissBehavior::Manual,
2407 layer: crate::overlay::OverlayLayer::InTree,
2408 parent_overlay: Some(parent),
2409 on_dismiss: None,
2410 fade_duration: None,
2411 });
2412
2413 assert_eq!(tree.active_overlays().len(), 2);
2414
2415 tree.dismiss_overlay(parent);
2416 assert!(tree.active_overlays().is_empty());
2417 assert!(!tree.is_visible(content_a));
2418 assert!(!tree.is_visible(content_b));
2419 }
2420
2421 #[test]
2422 fn dismissed_overlay_content_is_dormant_and_invisible() {
2423 let mut tree = WidgetTree::new();
2424 let anchor = tree.add(FillWidget::new());
2425 let content = tree.add(FillWidget::new());
2426 tree.layout(SizeProposal::exact(800.0, 600.0));
2427
2428 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2429 content_id: content,
2430 anchor,
2431 placement: crate::overlay::OverlayPlacement::Below,
2432 dismiss: crate::overlay::DismissBehavior::Manual,
2433 layer: crate::overlay::OverlayLayer::InTree,
2434 parent_overlay: None,
2435 on_dismiss: None,
2436 fade_duration: None,
2437 });
2438
2439 tree.layout(SizeProposal::exact(800.0, 600.0));
2440
2441 tree.dismiss_overlay(id);
2442 assert!(!tree.is_visible(content));
2443
2444 tree.layout(SizeProposal::exact(800.0, 600.0));
2445
2446 let center = tree.bounds(content).center();
2447 let hit = tree.hit_test(center);
2448 assert_ne!(hit, Some(content));
2449
2450 let _frame = tree.render();
2451 assert!(!tree.is_visible(content));
2452 }
2453
2454 #[test]
2455 fn tooltip_appears_after_delay() {
2456 let mut tree = WidgetTree::new();
2457 let anchor = tree.add(FillWidget::new());
2458 let tooltip = tree.add(FillWidget::new().label("Tooltip text"));
2459 tree.layout(SizeProposal::exact(200.0, 100.0));
2460
2461 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2462
2463 let center = tree.bounds(anchor).center();
2464 tree.pointer_move(center);
2465 assert!(tree.active_overlays().is_empty());
2466
2467 tree.advance_time(std::time::Duration::from_millis(600));
2468
2469 assert_eq!(tree.active_overlays().len(), 1);
2470 assert!(tree.find_by_label("Tooltip text").is_some());
2471 }
2472
2473 #[test]
2474 fn tooltip_survives_theme_switch() {
2475 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2480 let anchor = tree.add(FillWidget::new());
2481 let tooltip = tree.add(FillWidget::new().label("Tip"));
2482 tree.layout(SizeProposal::exact(200.0, 100.0));
2483
2484 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2485
2486 tree.set_theme(crate::presets::intui::dark());
2487
2488 tree.pointer_move(tree.bounds(anchor).center());
2489 tree.advance_time(std::time::Duration::from_millis(600));
2490
2491 assert_eq!(tree.active_overlays().len(), 1);
2492 assert!(tree.find_by_label("Tip").is_some());
2493 }
2494
2495 #[test]
2496 fn tooltip_suppressed_when_hovering_anchor_owned_overlay_content() {
2497 let mut tree = WidgetTree::new();
2505 let anchor = tree.add(FillWidget::new());
2506 let panel = tree.add_child(anchor, FillWidget::new());
2509 let row = tree.add_child(panel, FillWidget::new());
2510 let tip = tree.add(FillWidget::new().label("Tip"));
2511 tree.layout(SizeProposal::exact(200.0, 100.0));
2512
2513 let delay = std::time::Duration::from_millis(100);
2514 tree.attach_tooltip(anchor, tip, delay);
2515
2516 tree.tooltip_pointer_enter(anchor);
2519 tree.advance_time(delay + std::time::Duration::from_millis(50));
2520 assert_eq!(
2521 tree.active_overlays().len(),
2522 1,
2523 "tooltip should appear when hovering the anchor itself"
2524 );
2525 tree.pointer_move(Point::new(500.0, 500.0));
2528 tree.advance_time(std::time::Duration::from_millis(150));
2529 assert!(tree.active_overlays().is_empty());
2530
2531 tree.show_overlay(crate::overlay::OverlayRequest {
2533 content_id: panel,
2534 anchor,
2535 placement: crate::overlay::OverlayPlacement::Below,
2536 dismiss: crate::overlay::DismissBehavior::Manual,
2537 layer: crate::overlay::OverlayLayer::InTree,
2538 parent_overlay: None,
2539 on_dismiss: None,
2540 fade_duration: None,
2541 });
2542 assert_eq!(tree.active_overlays().len(), 1);
2543
2544 tree.tooltip_pointer_enter(row);
2547 tree.advance_time(delay + std::time::Duration::from_millis(50));
2548 assert_eq!(
2549 tree.active_overlays().len(),
2550 1,
2551 "anchor tooltip must not leak onto its own overlay's rows"
2552 );
2553 }
2554
2555 #[test]
2556 fn tooltip_inside_overlay_still_fires() {
2557 let mut tree = WidgetTree::new();
2561 let host = tree.add(FillWidget::new());
2562 let panel = tree.add_child(host, FillWidget::new());
2563 let row = tree.add_child(panel, FillWidget::new());
2564 let tip = tree.add(FillWidget::new().label("Row tip"));
2565 tree.layout(SizeProposal::exact(200.0, 100.0));
2566
2567 let delay = std::time::Duration::from_millis(100);
2568 tree.attach_tooltip(row, tip, delay);
2570
2571 tree.show_overlay(crate::overlay::OverlayRequest {
2572 content_id: panel,
2573 anchor: host,
2574 placement: crate::overlay::OverlayPlacement::Below,
2575 dismiss: crate::overlay::DismissBehavior::Manual,
2576 layer: crate::overlay::OverlayLayer::InTree,
2577 parent_overlay: None,
2578 on_dismiss: None,
2579 fade_duration: None,
2580 });
2581 assert_eq!(tree.active_overlays().len(), 1);
2582
2583 tree.tooltip_pointer_enter(row);
2584 tree.advance_time(delay + std::time::Duration::from_millis(50));
2585 assert_eq!(
2586 tree.active_overlays().len(),
2587 2,
2588 "a row's own tooltip should still fire inside an overlay"
2589 );
2590 }
2591
2592 #[test]
2593 fn tooltip_dismissed_on_pointer_leave() {
2594 let mut tree = WidgetTree::new();
2595 let anchor = tree.add(FillWidget::new());
2596 let tooltip = tree.add(FillWidget::new().label("Tip"));
2597 tree.layout(SizeProposal::exact(200.0, 100.0));
2598
2599 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2600
2601 tree.pointer_move(tree.bounds(anchor).center());
2602 tree.advance_time(std::time::Duration::from_millis(600));
2603 assert_eq!(tree.active_overlays().len(), 1);
2604
2605 tree.pointer_move(Point::new(500.0, 500.0));
2610 assert_eq!(
2611 tree.active_overlays().len(),
2612 1,
2613 "tooltip persists briefly after anchor-leave (hoverable grace)"
2614 );
2615 tree.advance_time(std::time::Duration::from_millis(150));
2616 assert!(
2617 tree.active_overlays().is_empty(),
2618 "tooltip dismissed after the 100ms grace outside anchor+overlay"
2619 );
2620 }
2621
2622 #[test]
2623 fn tooltip_not_shown_if_pointer_leaves_before_delay() {
2624 let mut tree = WidgetTree::new();
2625 let anchor = tree.add(FillWidget::new());
2626 let tooltip = tree.add(FillWidget::new().label("Tip"));
2627 tree.layout(SizeProposal::exact(200.0, 100.0));
2628
2629 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2630
2631 tree.pointer_move(tree.bounds(anchor).center());
2632 tree.advance_time(std::time::Duration::from_millis(200));
2633 tree.pointer_move(Point::new(500.0, 500.0));
2634
2635 tree.advance_time(std::time::Duration::from_millis(500));
2636 assert!(tree.active_overlays().is_empty());
2637 }
2638
2639 #[test]
2640 fn tooltip_reshow_uses_short_delay_after_prior_tip() {
2641 let mut tree = WidgetTree::new();
2644 let a = tree.add(FillWidget::new());
2645 let b = tree.add(FillWidget::new());
2646 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2647 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2648 tree.layout(SizeProposal::exact(400.0, 200.0));
2649
2650 let delay = std::time::Duration::from_millis(500);
2651 tree.attach_tooltip(a, tip_a, delay);
2652 tree.attach_tooltip(b, tip_b, delay);
2653
2654 tree.tooltip_pointer_enter(a);
2657 tree.advance_time(std::time::Duration::from_millis(150));
2658 assert!(
2659 tree.active_overlays().is_empty(),
2660 "must not appear before full initial delay"
2661 );
2662 tree.advance_time(std::time::Duration::from_millis(400));
2663 assert_eq!(
2664 tree.active_overlays().len(),
2665 1,
2666 "first tip after initial delay"
2667 );
2668 assert!(tree.find_by_label("Tip A").is_some());
2669
2670 let mut noop = crate::window::NoopWindowOps;
2672 tree.tooltip_pointer_leave(a, &mut noop);
2673 tree.tooltip_pointer_enter(b);
2674 tree.advance_time(std::time::Duration::from_millis(120));
2675 assert!(
2676 tree.find_by_label("Tip B").is_some(),
2677 "second tip should use the short reshow delay while session is warm"
2678 );
2679 }
2680
2681 #[test]
2682 fn reshow_delay_reverts_to_full_after_the_session_grace_expires() {
2683 let mut tree = WidgetTree::new();
2689 tree.set_accessibility_preferences(false, true, 1.0);
2693 let a = tree.add(FillWidget::new());
2694 let b = tree.add(FillWidget::new());
2695 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2696 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2697 tree.layout(SizeProposal::exact(400.0, 200.0));
2698
2699 let delay = std::time::Duration::from_millis(500);
2700 tree.attach_tooltip(a, tip_a, delay);
2701 tree.attach_tooltip(b, tip_b, delay);
2702
2703 tree.tooltip_pointer_enter(a);
2705 tree.advance_time(std::time::Duration::from_millis(550));
2706 assert!(tree.find_by_label("Tip A").is_some(), "first tip shown");
2707
2708 tree.pointer_move(Point::new(900.0, 900.0));
2709 tree.advance_time(std::time::Duration::from_millis(150));
2710 assert!(tree.active_overlays().is_empty(), "A dismissed on leave");
2711
2712 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2714
2715 tree.tooltip_pointer_enter(b);
2716 tree.advance_time(std::time::Duration::from_millis(150));
2717 assert!(
2718 tree.active_overlays().is_empty(),
2719 "session went cold — B must pay the FULL delay, not the 100 ms reshow"
2720 );
2721 tree.advance_time(std::time::Duration::from_millis(400));
2722 assert!(
2723 tree.find_by_label("Tip B").is_some(),
2724 "B still appears once its full delay elapses"
2725 );
2726 }
2727
2728 #[test]
2729 fn warm_reshow_scales_the_delay_rather_than_flattening_every_tier() {
2730 let mut tree = WidgetTree::new();
2736 let light = tree.add(FillWidget::new());
2737 let heavy = tree.add(FillWidget::new());
2738 let tip_light = tree.add(FillWidget::new().label("Light"));
2739 let tip_heavy = tree.add(FillWidget::new().label("Heavy"));
2740 tree.layout(SizeProposal::exact(400.0, 200.0));
2741
2742 tree.attach_tooltip(light, tip_light, std::time::Duration::from_millis(500));
2743 tree.attach_tooltip(heavy, tip_heavy, std::time::Duration::from_millis(700));
2744
2745 tree.tooltip_pointer_enter(light);
2747 tree.advance_time(std::time::Duration::from_millis(550));
2748 assert!(tree.find_by_label("Light").is_some(), "session warm");
2749
2750 let mut noop = crate::window::NoopWindowOps;
2751 tree.tooltip_pointer_leave(light, &mut noop);
2752 tree.tooltip_pointer_enter(heavy);
2753
2754 tree.advance_time(std::time::Duration::from_millis(120));
2756 assert!(
2757 tree.find_by_label("Heavy").is_none(),
2758 "a heavy tooltip must not fire at the light tier's reshow delay"
2759 );
2760 tree.advance_time(std::time::Duration::from_millis(40));
2762 assert!(
2763 tree.find_by_label("Heavy").is_some(),
2764 "heavy reshow is the scaled 140 ms"
2765 );
2766 }
2767
2768 #[test]
2769 fn a_pinned_sticky_tooltip_does_not_hold_the_session_warm() {
2770 let mut tree = WidgetTree::new();
2774 tree.set_accessibility_preferences(false, true, 1.0); let a = tree.add(FillWidget::new());
2776 let b = tree.add(FillWidget::new());
2777 let tip_a = tree.add(FillWidget::new().label("Pinned"));
2778 let tip_b = tree.add(FillWidget::new().label("Other"));
2779 tree.layout(SizeProposal::exact(400.0, 200.0));
2780
2781 tree.attach_tooltip_with_sticky(
2782 a,
2783 tip_a,
2784 std::time::Duration::from_millis(500),
2785 Some(std::time::Duration::from_millis(100)),
2786 );
2787 tree.attach_tooltip(b, tip_b, std::time::Duration::from_millis(500));
2788
2789 tree.tooltip_pointer_enter(a);
2790 tree.advance_time(std::time::Duration::from_millis(550));
2791 assert!(tree.find_by_label("Pinned").is_some());
2792 tree.promote_tooltip_to_sticky(tip_a);
2793
2794 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2796 tree.tooltip_pointer_enter(b);
2797 tree.advance_time(std::time::Duration::from_millis(150));
2798 assert!(
2799 tree.find_by_label("Other").is_none(),
2800 "a pinned sticky must not keep every other anchor on the reshow path"
2801 );
2802 }
2803
2804 #[test]
2805 fn reattaching_a_tooltip_does_not_grow_the_entry_table() {
2806 let mut tree = WidgetTree::new();
2812 let anchor = tree.add(FillWidget::new());
2813 tree.layout(SizeProposal::exact(200.0, 100.0));
2814
2815 let delay = std::time::Duration::from_millis(100);
2816 for _ in 0..25 {
2817 let tip = tree.add(FillWidget::new().label("Tip"));
2819 tree.attach_tooltip(anchor, tip, delay);
2820 assert_eq!(
2821 tree.tooltip_entry_count(),
2822 1,
2823 "an anchor must own exactly one tooltip entry across rebuilds"
2824 );
2825 }
2826
2827 tree.tooltip_pointer_enter(anchor);
2829 tree.advance_time(delay + std::time::Duration::from_millis(50));
2830 assert_eq!(
2831 tree.active_overlays().len(),
2832 1,
2833 "latest tooltip still shows"
2834 );
2835 }
2836
2837 #[test]
2838 fn destroying_an_anchor_reaps_its_tooltip_entry() {
2839 let mut tree = WidgetTree::new();
2842 let host = tree.add(FillWidget::new());
2843 let anchor = tree.add_child(host, FillWidget::new());
2844 let tip = tree.add(FillWidget::new().label("Tip"));
2845 tree.layout(SizeProposal::exact(200.0, 100.0));
2846
2847 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2848 assert_eq!(tree.tooltip_entry_count(), 1);
2849
2850 tree.destroy_subtree(anchor);
2851 assert_eq!(
2852 tree.tooltip_entry_count(),
2853 0,
2854 "destroying the anchor must reap its entry and content node"
2855 );
2856 }
2857
2858 #[test]
2859 fn a_leaving_tooltip_schedules_a_wake_for_its_dismissal() {
2860 let mut tree = WidgetTree::new();
2865 tree.set_accessibility_preferences(false, true, 1.0); let anchor = tree.add(FillWidget::new());
2867 let tip = tree.add(FillWidget::new().label("Tip"));
2868 tree.layout(SizeProposal::exact(400.0, 200.0));
2869
2870 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2871 tree.pointer_move(tree.bounds(anchor).center());
2872 tree.advance_time(std::time::Duration::from_millis(150));
2873 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2874
2875 assert!(
2877 tree.next_timer_deadline().is_none(),
2878 "a settled tooltip under the pointer schedules nothing"
2879 );
2880
2881 tree.pointer_move(Point::new(900.0, 900.0));
2884 assert!(
2885 tree.next_timer_deadline().is_some(),
2886 "the PointerLeave grace must contribute a wake deadline"
2887 );
2888 }
2889
2890 #[test]
2891 fn pressing_cancels_a_pending_dwell_and_dismisses_a_shown_tooltip() {
2892 let mut tree = WidgetTree::new();
2893 let anchor = tree.add(FillWidget::new());
2894 let tip = tree.add(FillWidget::new().label("Tip"));
2895 tree.layout(SizeProposal::exact(400.0, 200.0));
2896
2897 let delay = std::time::Duration::from_millis(500);
2898 tree.attach_tooltip(anchor, tip, delay);
2899
2900 tree.pointer_move(tree.bounds(anchor).center());
2903 tree.advance_time(std::time::Duration::from_millis(300));
2904 tree.tooltip_pointer_press(None);
2905 tree.advance_time(std::time::Duration::from_millis(400));
2906 assert!(
2907 tree.active_overlays().is_empty(),
2908 "a press must cancel the pending dwell, not merely delay it"
2909 );
2910
2911 tree.tooltip_pointer_enter(anchor);
2914 tree.advance_time(delay + std::time::Duration::from_millis(50));
2915 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown again");
2916 tree.tooltip_pointer_press(Some(tree.bounds(anchor).center()));
2917 assert!(
2918 tree.active_overlays().is_empty(),
2919 "a press must dismiss the shown tooltip"
2920 );
2921 }
2922
2923 #[test]
2924 fn window_deactivation_retires_hover_tooltips() {
2925 let mut tree = WidgetTree::new();
2926 let anchor = tree.add(FillWidget::new());
2927 let tip = tree.add(FillWidget::new().label("Tip"));
2928 tree.layout(SizeProposal::exact(400.0, 200.0));
2929
2930 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2931 tree.pointer_move(tree.bounds(anchor).center());
2932 tree.advance_time(std::time::Duration::from_millis(150));
2933 assert_eq!(tree.active_overlays().len(), 1);
2934
2935 tree.set_window_active(false);
2936 assert!(
2937 tree.active_overlays().is_empty(),
2938 "a tooltip must not float over another window's chrome"
2939 );
2940 }
2941
2942 #[test]
2943 fn only_the_innermost_anchor_arms_its_dwell() {
2944 let mut tree = WidgetTree::new();
2947 let panel = tree.add(FillWidget::new());
2948 let row = tree.add_child(panel, FillWidget::new());
2949 let panel_tip = tree.add(FillWidget::new().label("Panel"));
2950 let row_tip = tree.add(FillWidget::new().label("Row"));
2951 tree.layout(SizeProposal::exact(400.0, 200.0));
2952
2953 let delay = std::time::Duration::from_millis(100);
2954 tree.attach_tooltip(panel, panel_tip, delay);
2955 tree.attach_tooltip(row, row_tip, delay);
2956
2957 tree.tooltip_pointer_enter(row);
2958 tree.advance_time(delay + std::time::Duration::from_millis(50));
2959
2960 assert_eq!(
2961 tree.active_overlays().len(),
2962 1,
2963 "exactly one tooltip may open for a hover"
2964 );
2965 assert!(
2966 tree.find_by_label("Row").is_some(),
2967 "the innermost anchor wins"
2968 );
2969 }
2970
2971 #[test]
2972 fn escape_dismisses_a_hover_tooltip_and_falls_through_to_the_menu_below() {
2973 let mut tree = WidgetTree::new();
2977 tree.set_accessibility_preferences(false, true, 1.0); let anchor = tree.add(FillWidget::new());
2979 let menu = tree.add(FillWidget::new());
2980 let tip = tree.add(FillWidget::new().label("Tip"));
2981 tree.layout(SizeProposal::exact(400.0, 200.0));
2982
2983 let menu_overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2984 content_id: menu,
2985 anchor,
2986 placement: crate::overlay::OverlayPlacement::Below,
2987 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2988 layer: crate::overlay::OverlayLayer::InTree,
2989 parent_overlay: None,
2990 on_dismiss: None,
2991 fade_duration: None,
2992 });
2993 assert_eq!(tree.active_overlays().len(), 1);
2994
2995 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2997 tree.tooltip_pointer_enter(anchor);
2998 tree.advance_time(std::time::Duration::from_millis(150));
2999 assert_eq!(
3000 tree.active_overlays().len(),
3001 2,
3002 "tooltip sits above the menu"
3003 );
3004
3005 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3007 assert!(dismissed.is_some(), "Escape must dismiss the hover tooltip");
3008 assert_eq!(tree.active_overlays().len(), 1);
3009
3010 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3012 assert_eq!(
3013 dismissed.map(|(id, _, _)| id),
3014 Some(menu_overlay),
3015 "Escape must then reach the menu underneath"
3016 );
3017 assert!(tree.active_overlays().is_empty());
3018 }
3019
3020 #[test]
3021 fn tooltip_timer_restarts_when_pointer_keeps_moving() {
3022 let mut tree = WidgetTree::new();
3025 let anchor = tree.add(FillWidget::new());
3026 let tooltip = tree.add(FillWidget::new().label("Tip"));
3027 tree.layout(SizeProposal::exact(200.0, 100.0));
3028
3029 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3030
3031 let start = tree.bounds(anchor).center();
3032 tree.pointer_move(start);
3033 tree.advance_time(std::time::Duration::from_millis(300));
3034 tree.pointer_move(Point::new(start.x + 20.0, start.y));
3036 tree.advance_time(std::time::Duration::from_millis(300));
3039 assert!(
3040 tree.active_overlays().is_empty(),
3041 "moving past stationary slop must restart the delay"
3042 );
3043 tree.advance_time(std::time::Duration::from_millis(250));
3044 assert_eq!(
3045 tree.active_overlays().len(),
3046 1,
3047 "tooltip appears after a full delay of stillness"
3048 );
3049 }
3050
3051 #[test]
3052 fn timed_overlay_auto_dismisses_after_duration() {
3053 let mut tree = WidgetTree::new();
3054 let anchor = tree.add(FillWidget::new());
3055 let content = tree.add(FillWidget::new().label("Toast"));
3056 tree.layout(SizeProposal::exact(200.0, 100.0));
3057
3058 tree.show_overlay_for(
3059 crate::overlay::OverlayRequest {
3060 content_id: content,
3061 anchor,
3062 placement: crate::overlay::OverlayPlacement::Below,
3063 dismiss: crate::overlay::DismissBehavior::Manual,
3064 layer: crate::overlay::OverlayLayer::InTree,
3065 parent_overlay: None,
3066 on_dismiss: None,
3067 fade_duration: None,
3068 },
3069 std::time::Duration::from_millis(300),
3070 );
3071
3072 assert_eq!(tree.active_overlays().len(), 1);
3073
3074 tree.advance_time(std::time::Duration::from_millis(200));
3075 assert_eq!(tree.active_overlays().len(), 1);
3076
3077 tree.advance_time(std::time::Duration::from_millis(150));
3078 assert!(tree.active_overlays().is_empty());
3079 assert!(!tree.is_visible(content));
3080 }
3081
3082 #[test]
3083 fn fade_dismiss_keeps_overlay_off_active_list_immediately() {
3084 let mut tree = WidgetTree::new();
3092 let anchor = tree.add(FillWidget::new());
3093 let content = tree.add(FillWidget::new().label("Faded"));
3094 tree.layout(SizeProposal::exact(200.0, 100.0));
3095
3096 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3097 content_id: content,
3098 anchor,
3099 placement: crate::overlay::OverlayPlacement::Below,
3100 dismiss: crate::overlay::DismissBehavior::Manual,
3101 layer: crate::overlay::OverlayLayer::InTree,
3102 parent_overlay: None,
3103 on_dismiss: None,
3104 fade_duration: Some(std::time::Duration::from_millis(100)),
3105 });
3106 assert_eq!(tree.active_overlays().len(), 1);
3107
3108 tree.dismiss_overlay(id);
3109 assert!(tree.active_overlays().is_empty());
3114 }
3115
3116 #[test]
3117 fn fade_dismiss_defers_content_dormancy_until_sim_tween_completes() {
3118 let mut tree = WidgetTree::new();
3124 let anchor = tree.add(FillWidget::new());
3125 let content = tree.add(FillWidget::new().label("Faded"));
3126 tree.layout(SizeProposal::exact(200.0, 100.0));
3127
3128 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3129 content_id: content,
3130 anchor,
3131 placement: crate::overlay::OverlayPlacement::Below,
3132 dismiss: crate::overlay::DismissBehavior::Manual,
3133 layer: crate::overlay::OverlayLayer::InTree,
3134 parent_overlay: None,
3135 on_dismiss: None,
3136 fade_duration: Some(std::time::Duration::from_millis(100)),
3137 });
3138 tree.dismiss_overlay(id);
3139 assert!(
3140 tree.is_visible(content),
3141 "content stays active during fade-out"
3142 );
3143
3144 tree.advance_time(std::time::Duration::from_millis(150));
3145 assert!(
3146 !tree.is_visible(content),
3147 "after sim-time past the tween window, deferred removal fires"
3148 );
3149 }
3150}