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 if pending.replace_siblings {
626 let anchor = pending.request.anchor;
627 self.dismiss_child_overlays_for_source(anchor, Some(content_id), &mut *ops);
628 }
629 self.arena.activate(content_id);
630 let current_focus = self.focused;
631 self.overlay_manager.show(pending.request);
632 if let Some(focus_id) = current_focus {
633 self.overlay_manager.set_top_focus_restore(focus_id);
634 }
635 self.arena.mark_needs_paint(content_id);
636 if let Some(focus_target) = pending.focus_target
637 && self.arena.is_active(focus_target)
638 {
639 self.focus_ops(focus_target, &mut *ops);
640 }
641 }
642 if any_shown {
643 self.a11y_dirty = true;
644 }
645 }
646
647 pub(super) fn overlay_ancestor_for_widget(
648 &self,
649 widget_id: WidgetId,
650 ) -> Option<crate::overlay::OverlayId> {
651 self.overlay_manager
652 .stack
653 .iter()
654 .rev()
655 .find(|overlay| self.is_descendant_of(widget_id, overlay.content_id))
656 .map(|overlay| overlay.id)
657 }
658
659 pub(super) fn modal_overlay_for_widget(
660 &self,
661 widget_id: WidgetId,
662 ) -> Option<crate::overlay::OverlayId> {
663 let mut current = self.overlay_ancestor_for_widget(widget_id);
664
665 while let Some(overlay_id) = current {
666 let overlay = self.overlay_manager.overlay(overlay_id)?;
667 if matches!(
668 overlay.placement,
669 crate::overlay::OverlayPlacement::Centered
670 ) {
671 return Some(overlay_id);
672 }
673 current = overlay.parent_overlay;
674 }
675
676 None
677 }
678
679 fn menu_ancestor_for_widget(&self, widget_id: WidgetId) -> Option<WidgetId> {
680 let mut current = Some(widget_id);
681 while let Some(id) = current {
682 if let Some(node) = self.arena.get(id) {
683 let mut builder = AccessNodeBuilder::new();
684 node.widget.accessibility(&mut builder);
685 if builder.role() == accesskit::Role::Menu {
686 return Some(id);
687 }
688 }
689 current = self.arena.parent(id);
690 }
691 None
692 }
693
694 pub(super) fn dismiss_child_overlays_for_source(
695 &mut self,
696 source_widget: WidgetId,
697 preserve_content: Option<WidgetId>,
698 ops: &mut dyn crate::window::WindowOps,
699 ) {
700 if let Some(parent_overlay) = self.overlay_ancestor_for_widget(source_widget) {
701 let preserve_overlay = preserve_content
702 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
703 let (dismissed, focus_restore) = self
704 .overlay_manager
705 .dismiss_descendants_of(parent_overlay, preserve_overlay);
706 self.dormant_dismissed_content(&dismissed, &mut *ops);
707 if let Some(restore_id) = focus_restore
708 && self.arena.is_active(restore_id)
709 {
710 self.focus_ops(restore_id, &mut *ops);
711 }
712 return;
713 }
714
715 let Some(menu_root) = self.menu_ancestor_for_widget(source_widget) else {
716 return;
717 };
718 let preserve_overlay = preserve_content
719 .and_then(|content_id| self.overlay_manager.find_by_content(content_id));
720 let overlay_ids: Vec<crate::overlay::OverlayId> = self
721 .overlay_manager
722 .stack
723 .iter()
724 .filter(|overlay| {
725 overlay.parent_overlay.is_none()
726 && self.is_descendant_of(overlay.anchor, menu_root)
727 && !preserve_overlay.is_some_and(|keep| {
728 overlay.id == keep
729 || self.overlay_manager.is_descendant_of(overlay.id, keep)
730 })
731 })
732 .map(|overlay| overlay.id)
733 .collect();
734
735 for overlay_id in overlay_ids {
736 let (dismissed, focus_restore) =
737 self.overlay_manager.dismiss_with_focus_restore(overlay_id);
738 self.dormant_dismissed_content(&dismissed, &mut *ops);
739 if let Some(restore_id) = focus_restore
740 && self.arena.is_active(restore_id)
741 {
742 self.focus_ops(restore_id, &mut *ops);
743 }
744 }
745 }
746
747 pub(super) fn dismiss_self_overlay_chain_for_source(
759 &mut self,
760 source_widget: WidgetId,
761 ops: &mut dyn crate::window::WindowOps,
762 ) {
763 let Some(start) = self.overlay_ancestor_for_widget(source_widget) else {
764 return;
765 };
766
767 let mut topmost_to_dismiss: Option<crate::overlay::OverlayId> = None;
768 let mut current = Some(start);
769 while let Some(overlay_id) = current {
770 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
771 break;
772 };
773 if self.overlay_is_host_surface(overlay_id) {
774 break;
775 }
776 topmost_to_dismiss = Some(overlay_id);
777 current = overlay.parent_overlay;
778 }
779
780 if let Some(target) = topmost_to_dismiss {
781 let (dismissed, focus_restore) =
782 self.overlay_manager.dismiss_with_focus_restore(target);
783 self.dormant_dismissed_content(&dismissed, &mut *ops);
784 if let Some(restore_id) = focus_restore
785 && self.arena.is_active(restore_id)
786 {
787 self.focus_ops(restore_id, &mut *ops);
788 }
789 }
790 }
791
792 fn overlay_is_anchor_positioned(placement: &crate::overlay::OverlayPlacement) -> bool {
809 use crate::overlay::OverlayPlacement as P;
810 match placement {
811 P::Below
812 | P::Above
813 | P::TrailingEdge
814 | P::AtPointer(_)
815 | P::NearAnchor { .. }
816 | P::BelowPreferred => true,
817 P::Centered | P::BottomCenter | P::ViewportCorner { .. } | P::FullViewport => false,
818 }
819 }
820
821 fn overlay_follows_focus_out(&self, overlay_id: crate::overlay::OverlayId) -> bool {
856 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
857 return false;
858 };
859 if !Self::overlay_is_anchor_positioned(&overlay.placement) {
860 return false;
861 }
862 if overlay.is_dismissing() {
863 return false;
864 }
865 !self
866 .tooltips
867 .iter()
868 .any(|entry| entry.content_id == overlay.content_id)
869 }
870
871 fn overlay_orbit_for_widget(&self, widget_id: WidgetId) -> Option<crate::overlay::OverlayId> {
894 self.overlay_manager
895 .stack
896 .iter()
897 .rev()
898 .find(|overlay| {
899 self.is_descendant_of(widget_id, overlay.content_id)
900 || (self.overlay_follows_focus_out(overlay.id)
901 && self.is_descendant_of(widget_id, overlay.anchor))
902 })
903 .map(|overlay| overlay.id)
904 }
905
906 pub(super) fn dismiss_overlays_left_by_focus(
933 &mut self,
934 old: Option<WidgetId>,
935 new_focus: WidgetId,
936 ops: &mut dyn crate::window::WindowOps,
937 ) {
938 let Some(old) = old else {
939 return;
940 };
941 let Some(old_overlay) = self.overlay_orbit_for_widget(old) else {
942 return;
943 };
944 let new_overlay = self.overlay_ancestor_for_widget(new_focus);
950
951 if let Some(new_overlay) = new_overlay
954 && (new_overlay == old_overlay
955 || self
956 .overlay_manager
957 .is_descendant_of(new_overlay, old_overlay))
958 {
959 return;
960 }
961
962 if !self.overlay_follows_focus_out(old_overlay) {
963 return;
964 }
965
966 let mut topmost = old_overlay;
967 let mut current = self
968 .overlay_manager
969 .overlay(old_overlay)
970 .and_then(|overlay| overlay.parent_overlay);
971 while let Some(overlay_id) = current {
972 if Some(overlay_id) == new_overlay {
976 break;
977 }
978 if self.overlay_is_host_surface(overlay_id) {
981 break;
982 }
983 if !self.overlay_follows_focus_out(overlay_id) {
984 break;
985 }
986 topmost = overlay_id;
987 current = self
988 .overlay_manager
989 .overlay(overlay_id)
990 .and_then(|overlay| overlay.parent_overlay);
991 }
992
993 let dismissed = self.overlay_manager.dismiss(topmost);
994 self.dormant_dismissed_content(&dismissed, &mut *ops);
995 }
996
997 pub(super) fn dismiss_all_overlays_except_hosts(
1008 &mut self,
1009 ops: &mut dyn crate::window::WindowOps,
1010 ) {
1011 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1012 .overlay_manager
1013 .stack
1014 .iter()
1015 .map(|overlay| overlay.id)
1016 .filter(|&id| !self.overlay_is_host_surface(id))
1017 .collect();
1018
1019 for overlay_id in to_dismiss {
1020 let dismissed = self.overlay_manager.dismiss(overlay_id);
1021 self.dormant_dismissed_content(&dismissed, &mut *ops);
1022 }
1023 }
1024
1025 pub(super) fn overlay_is_host_surface(&self, overlay_id: crate::overlay::OverlayId) -> bool {
1031 let Some(overlay) = self.overlay_manager.overlay(overlay_id) else {
1032 return false;
1033 };
1034 if matches!(
1043 overlay.placement,
1044 crate::overlay::OverlayPlacement::Centered
1045 ) {
1046 return true;
1047 }
1048 let Some(node) = self.arena.get(overlay.content_id) else {
1049 return false;
1050 };
1051 let role = node
1059 .access_overrides
1060 .as_ref()
1061 .and_then(|o| o.role)
1062 .unwrap_or_else(|| {
1063 let mut builder = AccessNodeBuilder::new();
1064 node.widget.accessibility(&mut builder);
1065 builder.role()
1066 });
1067 matches!(
1068 role,
1069 accesskit::Role::Tooltip
1070 | accesskit::Role::Dialog
1071 | accesskit::Role::AlertDialog
1072 | accesskit::Role::MenuBar
1073 )
1074 }
1075
1076 pub(super) fn dismiss_modal_for_source(
1077 &mut self,
1078 source_widget: WidgetId,
1079 ops: &mut dyn crate::window::WindowOps,
1080 ) -> bool {
1081 let Some(modal_overlay) = self.modal_overlay_for_widget(source_widget) else {
1082 return false;
1083 };
1084
1085 let (dismissed, focus_restore) = self
1086 .overlay_manager
1087 .dismiss_with_focus_restore(modal_overlay);
1088 self.dormant_dismissed_content(&dismissed, &mut *ops);
1089 if let Some(restore_id) = focus_restore
1090 && self.arena.is_active(restore_id)
1091 {
1092 self.focus_ops(restore_id, &mut *ops);
1093 }
1094 true
1095 }
1096
1097 pub fn is_descendant_of(&self, widget_id: WidgetId, ancestor: WidgetId) -> bool {
1098 if widget_id == ancestor {
1099 return true;
1100 }
1101 let mut current = self.arena.parent(widget_id);
1102 while let Some(parent_id) = current {
1103 if parent_id == ancestor {
1104 return true;
1105 }
1106 current = self.arena.parent(parent_id);
1107 }
1108 false
1109 }
1110
1111 fn is_overlay_content_root(&self, widget_id: WidgetId) -> bool {
1118 self.overlay_manager.find_by_content(widget_id).is_some()
1119 }
1120
1121 pub(super) fn tooltip_focus_enter(&mut self, widget_id: WidgetId) {
1150 type ToShow = (WidgetId, WidgetId, crate::overlay::TooltipPlacement);
1173 let mut direct: Vec<ToShow> = Vec::new();
1174 let mut reverse: Vec<ToShow> = Vec::new();
1175 for e in &self.tooltips {
1176 if e.sticky_after.is_none() || e.overlay_id.is_some() || e.suppressed_until_focus_leaves
1177 {
1178 continue;
1179 }
1180 if self.is_descendant_of(widget_id, e.anchor_id) {
1181 direct.push((e.anchor_id, e.content_id, e.placement));
1182 } else if self.is_descendant_of(e.anchor_id, widget_id) {
1183 reverse.push((e.anchor_id, e.content_id, e.placement));
1184 }
1185 }
1186 let mut to_show = direct;
1187 if reverse.len() == 1 && !self.is_overlay_content_root(widget_id) {
1191 to_show.extend(reverse);
1192 }
1193
1194 let sim_now = self.sim_clock;
1203 let real_now = std::time::Instant::now();
1204 for (_anchor_id, content_id, _placement) in to_show {
1205 if let Some(entry) = self
1206 .tooltips
1207 .iter_mut()
1208 .find(|e| e.content_id == content_id)
1209 {
1210 entry.hover_start = Some(sim_now);
1211 entry.real_hover_start = Some(real_now);
1212 entry.hover_origin = None;
1213 entry.armed_by_focus = true;
1214 }
1215 }
1216 }
1217
1218 pub(super) fn show_highlight_tooltip(
1228 &mut self,
1229 item_id: WidgetId,
1230 ops: &mut dyn crate::window::WindowOps,
1231 ) {
1232 self.clear_highlight_tooltip(&mut *ops);
1235
1236 let found = self.tooltips.iter().find_map(|e| {
1240 if e.overlay_id.is_none() && self.is_descendant_of(e.anchor_id, item_id) {
1241 Some((e.anchor_id, e.content_id, e.placement))
1242 } else {
1243 None
1244 }
1245 });
1246 let Some((anchor_id, content_id, placement)) = found else {
1247 return;
1248 };
1249
1250 let parent_overlay = self.overlay_ancestor_for_widget(item_id);
1251 self.arena.activate(content_id);
1252 let fade_duration = if self.prefers_reduced_motion {
1253 None
1254 } else {
1255 Some(self.theme.motion.duration_fast)
1256 };
1257 let oid = self.show_overlay(crate::overlay::OverlayRequest {
1258 content_id,
1259 anchor: anchor_id,
1260 placement: Self::tooltip_overlay_placement(placement),
1261 dismiss: crate::overlay::DismissBehavior::Manual,
1262 layer: crate::overlay::OverlayLayer::InTree,
1263 parent_overlay,
1264 on_dismiss: None,
1265 fade_duration,
1266 });
1267 let real_now = std::time::Instant::now();
1268 let sim_now = self.sim_clock;
1269 if let Some(entry) = self
1270 .tooltips
1271 .iter_mut()
1272 .find(|e| e.content_id == content_id)
1273 {
1274 entry.overlay_id = Some(oid);
1275 entry.shown_at_sim = Some(sim_now);
1276 entry.shown_at_real = Some(real_now);
1277 if let Some(sink) = entry.shown_at_sink.as_ref() {
1278 sink.set(Some(real_now));
1279 }
1280 }
1281 self.highlight_tooltip = Some((oid, content_id));
1282 }
1283
1284 pub(super) fn clear_highlight_tooltip(&mut self, ops: &mut dyn crate::window::WindowOps) {
1289 let Some((oid, content_id)) = self.highlight_tooltip.take() else {
1290 return;
1291 };
1292 if self.overlay_manager.stack.iter().any(|o| o.id == oid) {
1293 let dismissed = self.overlay_manager.dismiss(oid);
1294 self.dormant_dismissed_content(&dismissed, &mut *ops);
1295 }
1296 if let Some(entry) = self
1297 .tooltips
1298 .iter_mut()
1299 .find(|e| e.content_id == content_id)
1300 {
1301 entry.overlay_id = None;
1302 entry.shown_at_sim = None;
1303 entry.shown_at_real = None;
1304 if let Some(sink) = entry.shown_at_sink.as_ref() {
1305 sink.set(None);
1306 }
1307 }
1308 }
1309
1310 pub(super) fn tooltip_focus_leave_outside(
1321 &mut self,
1322 new_focus: Option<WidgetId>,
1323 ops: &mut dyn crate::window::WindowOps,
1324 ) {
1325 let to_dismiss: Vec<crate::overlay::OverlayId> = self
1326 .tooltips
1327 .iter()
1328 .filter(|e| e.promoted_by_focus && e.overlay_id.is_some())
1329 .filter(|e| {
1330 let in_scope = new_focus
1331 .map(|nf| {
1332 self.is_descendant_of(nf, e.anchor_id)
1337 || self.is_descendant_of(e.anchor_id, nf)
1338 || self.is_descendant_of(nf, e.content_id)
1339 })
1340 .unwrap_or(false);
1341 !in_scope
1342 })
1343 .filter_map(|e| e.overlay_id)
1344 .collect();
1345
1346 for oid in to_dismiss {
1347 let dismissed = self.overlay_manager.dismiss(oid);
1348 self.dormant_dismissed_content(&dismissed, &mut *ops);
1349 }
1350
1351 for e in &mut self.tooltips {
1354 if e.armed_by_focus && e.overlay_id.is_none() {
1355 e.hover_start = None;
1356 e.real_hover_start = None;
1357 e.armed_by_focus = false;
1358 }
1359 }
1360
1361 let served: Vec<usize> = self
1365 .tooltips
1366 .iter()
1367 .enumerate()
1368 .filter(|(_, e)| e.suppressed_until_focus_leaves)
1369 .filter(|(_, e)| {
1370 let still_inside = new_focus
1371 .map(|nf| {
1372 self.is_descendant_of(nf, e.anchor_id)
1373 || self.is_descendant_of(nf, e.content_id)
1374 })
1375 .unwrap_or(false);
1376 !still_inside
1377 })
1378 .map(|(i, _)| i)
1379 .collect();
1380 for i in served {
1381 self.tooltips[i].suppressed_until_focus_leaves = false;
1382 }
1383 }
1384
1385 fn tooltip_hover_targets_anchor(&self, widget_id: WidgetId, anchor_id: WidgetId) -> bool {
1405 if !self.is_descendant_of(widget_id, anchor_id) {
1406 return false;
1407 }
1408 if let Some(overlay_id) = self.overlay_ancestor_for_widget(widget_id)
1409 && let Some(content_id) = self
1410 .overlay_manager
1411 .overlay(overlay_id)
1412 .map(|o| o.content_id)
1413 && !self.is_descendant_of(anchor_id, content_id)
1414 {
1415 return false;
1416 }
1417 true
1418 }
1419
1420 pub(super) fn tooltip_pointer_enter(&mut self, widget_id: WidgetId) {
1432 let innermost: Option<usize> = self
1433 .tooltips
1434 .iter()
1435 .enumerate()
1436 .filter(|(_, entry)| self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id))
1437 .filter_map(|(index, entry)| {
1438 self.ancestor_distance(widget_id, entry.anchor_id)
1439 .map(|depth| (depth, index))
1440 })
1441 .min()
1442 .map(|(_, index)| index);
1443 let Some(index) = innermost else {
1444 return;
1445 };
1446 if self.tooltips[index].overlay_id.is_some() {
1448 return;
1449 }
1450 self.tooltips[index].hover_start = Some(self.sim_clock);
1451 self.tooltips[index].real_hover_start = Some(std::time::Instant::now());
1452 self.tooltips[index].hover_origin = self.last_pointer_position;
1453 self.tooltips[index].armed_by_focus = false;
1454 self.arena.mark_needs_paint(self.tooltips[index].anchor_id);
1455 }
1456
1457 fn ancestor_distance(&self, widget_id: WidgetId, ancestor: WidgetId) -> Option<usize> {
1460 let mut current = widget_id;
1461 let mut depth = 0usize;
1462 loop {
1463 if current == ancestor {
1464 return Some(depth);
1465 }
1466 current = self.arena.parent(current)?;
1467 depth += 1;
1468 }
1469 }
1470
1471 pub(super) fn tooltip_pointer_press(&mut self, at: Option<teksilo_canvas::Point>) {
1486 let mut to_dismiss = Vec::new();
1487 let candidates: Vec<(usize, Option<crate::overlay::OverlayId>)> = self
1488 .tooltips
1489 .iter()
1490 .enumerate()
1491 .map(|(i, e)| (i, if e.is_sticky { None } else { e.overlay_id }))
1492 .collect();
1493 for (index, overlay_id) in candidates {
1494 let inside = match (overlay_id, at) {
1499 (Some(oid), Some(pos)) => self
1500 .overlay_content_bounds(oid)
1501 .is_some_and(|rect| rect.contains(pos)),
1502 _ => false,
1503 };
1504 if inside {
1505 continue;
1506 }
1507 let entry = &mut self.tooltips[index];
1508 entry.hover_start = None;
1509 entry.real_hover_start = None;
1510 entry.hover_origin = None;
1511 if let Some(oid) = overlay_id {
1512 to_dismiss.push(oid);
1513 }
1514 }
1515 for overlay_id in to_dismiss {
1516 self.dismiss_overlay(overlay_id);
1517 }
1518 }
1519
1520 pub(super) fn tooltip_escape_pressed(&mut self) {
1542 self.tooltip_pointer_press(None);
1545 }
1546
1547 pub(super) fn tooltip_window_deactivated(&mut self) {
1554 self.tooltip_pointer_press(None);
1557 }
1558
1559 pub(crate) fn tooltip_cancel_pending_dwell(&mut self) {
1567 for entry in &mut self.tooltips {
1568 if entry.overlay_id.is_none() {
1569 entry.hover_start = None;
1570 entry.real_hover_start = None;
1571 entry.hover_origin = None;
1572 }
1573 }
1574 }
1575
1576 pub(super) fn tooltip_pointer_moved(
1580 &mut self,
1581 widget_id: WidgetId,
1582 position: teksilo_canvas::Point,
1583 ) {
1584 let matching: Vec<usize> = self
1585 .tooltips
1586 .iter()
1587 .enumerate()
1588 .filter(|(_, entry)| {
1589 entry.overlay_id.is_none()
1590 && entry.hover_start.is_some()
1591 && !entry.armed_by_focus
1592 && self.tooltip_hover_targets_anchor(widget_id, entry.anchor_id)
1593 })
1594 .map(|(index, _)| index)
1595 .collect();
1596 if matching.is_empty() {
1597 return;
1598 }
1599 let now = self.sim_clock;
1600 let real_now = std::time::Instant::now();
1601 let slop = super::TOOLTIP_STATIONARY_SLOP;
1602 let slop_sq = slop * slop;
1603 for index in matching {
1604 let origin = match self.tooltips[index].hover_origin {
1605 Some(o) => o,
1606 None => {
1607 self.tooltips[index].hover_origin = Some(position);
1611 continue;
1612 }
1613 };
1614 let dx = position.x - origin.x;
1615 let dy = position.y - origin.y;
1616 if dx * dx + dy * dy <= slop_sq {
1617 continue;
1618 }
1619 self.tooltips[index].hover_start = Some(now);
1621 self.tooltips[index].real_hover_start = Some(real_now);
1622 self.tooltips[index].hover_origin = Some(position);
1623 }
1624 }
1625
1626 pub fn promote_tooltip_to_sticky(&mut self, content_id: WidgetId) {
1643 let Some(entry) = self
1644 .tooltips
1645 .iter_mut()
1646 .find(|entry| entry.content_id == content_id)
1647 else {
1648 return;
1649 };
1650 if entry.is_sticky {
1651 return;
1652 }
1653 entry.is_sticky = true;
1654 let overlay_id = entry.overlay_id;
1655 if let Some(overlay_id) = overlay_id {
1656 self.overlay_manager.set_dismiss(
1657 overlay_id,
1658 crate::overlay::DismissBehavior::EscapeOrClickOutside,
1659 );
1660 }
1661 }
1662
1663 pub(super) fn tooltip_pointer_leave(
1664 &mut self,
1665 widget_id: WidgetId,
1666 _ops: &mut dyn crate::window::WindowOps,
1667 ) {
1668 let matching: Vec<usize> = self
1669 .tooltips
1670 .iter()
1671 .enumerate()
1672 .filter(|(_, entry)| self.is_descendant_of(widget_id, entry.anchor_id))
1673 .map(|(index, _)| index)
1674 .collect();
1675 for index in matching {
1676 self.tooltips[index].hover_start = None;
1679 self.tooltips[index].real_hover_start = None;
1680 self.tooltips[index].hover_origin = None;
1681 }
1693 }
1694
1695 pub fn next_timer_deadline(&self) -> Option<std::time::Instant> {
1697 let now = std::time::Instant::now();
1698 let session_active = self.tooltip_session_active_real(now);
1699 let tooltip_deadline = self
1700 .tooltips
1701 .iter()
1702 .filter(|entry| entry.overlay_id.is_none())
1703 .filter_map(|entry| {
1704 let start = entry.real_hover_start?;
1705 let delay = self.effective_tooltip_delay(entry.delay, session_active);
1706 Some(start + delay)
1707 })
1708 .min();
1709
1710 let ref_time = self.last_frame_time.unwrap_or_else(std::time::Instant::now);
1731 let dwell_tooltip_deadline = self
1732 .tooltips
1733 .iter()
1734 .filter_map(|entry| {
1735 let sticky_after = entry.sticky_after?;
1736 let shown_at = entry.shown_at_real?;
1737 if entry.overlay_id.is_none() || entry.is_sticky {
1738 return None;
1739 }
1740 let dwell_step = sticky_after / super::TOOLTIP_DWELL_STEPS;
1741 if dwell_step.is_zero() {
1742 return None;
1743 }
1744 let elapsed = ref_time.saturating_duration_since(shown_at);
1745 if elapsed >= sticky_after {
1746 return None;
1747 }
1748 let steps_passed = (elapsed.as_millis() / dwell_step.as_millis()) as u32;
1751 let next_step_at = shown_at + dwell_step * (steps_passed + 1);
1752 Some(next_step_at.min(shown_at + sticky_after))
1753 })
1754 .min();
1755 let delayed_overlay_deadline = self
1756 .pending_delayed_overlays
1757 .iter()
1758 .map(|pending| pending.real_requested_at + pending.delay)
1759 .min();
1760 let auto_dismiss_deadline = self.overlay_manager.next_auto_dismiss_deadline();
1761 let pointer_leave_deadline = self.overlay_manager.next_pointer_leave_deadline();
1768 let animation_deadline = self
1769 .animation_scheduler
1770 .next_deadline(&self.arena, self.paint_epoch);
1771 let animated_quad_deadline = self
1776 .animated_quads
1777 .next_deadline(&self.arena, self.paint_epoch);
1778 let gesture_deadline = self.next_gesture_deadline();
1779 let wake_at_deadline = self.pending_wake_at.get();
1780 let frame_tick_deadline = self.frame_tick_deadline();
1786
1787 [
1788 tooltip_deadline,
1789 dwell_tooltip_deadline,
1790 delayed_overlay_deadline,
1791 auto_dismiss_deadline,
1792 pointer_leave_deadline,
1793 animation_deadline,
1794 animated_quad_deadline,
1795 gesture_deadline,
1796 wake_at_deadline,
1797 frame_tick_deadline,
1798 ]
1799 .into_iter()
1800 .flatten()
1801 .min()
1802 }
1803
1804 pub fn overlay_manager(&self) -> &crate::overlay::OverlayManager {
1805 &self.overlay_manager
1806 }
1807
1808 pub fn overlay_manager_mut(&mut self) -> &mut crate::overlay::OverlayManager {
1813 &mut self.overlay_manager
1814 }
1815
1816 pub fn active_overlays(&self) -> Vec<crate::overlay::OverlayId> {
1817 self.overlay_manager.active_ids()
1818 }
1819
1820 pub fn overlay_content_bounds(
1829 &self,
1830 id: crate::overlay::OverlayId,
1831 ) -> Option<teksilo_canvas::Rect> {
1832 self.overlay_manager
1833 .stack
1834 .iter()
1835 .find(|o| o.id == id)
1836 .map(|o| o.bounds)
1837 }
1838
1839 pub fn show_overlay(
1840 &mut self,
1841 request: crate::overlay::OverlayRequest,
1842 ) -> crate::overlay::OverlayId {
1843 let fade_duration = request.fade_duration;
1844 let content_id = request.content_id;
1845 let id = self.overlay_manager.show(request);
1846 self.a11y_dirty = true;
1854 if let Some(duration) = fade_duration {
1855 self.attach_overlay_fade(id, content_id, duration);
1856 }
1857 id
1858 }
1859
1860 pub fn show_overlay_from_source(
1863 &mut self,
1864 source_widget: WidgetId,
1865 mut request: crate::overlay::OverlayRequest,
1866 ) -> crate::overlay::OverlayId {
1867 if request.parent_overlay.is_none() {
1868 request.parent_overlay = self.overlay_ancestor_for_widget(source_widget);
1869 }
1870 if let Some(existing) = self.overlay_manager.find_by_content(request.content_id) {
1871 return existing;
1872 }
1873
1874 let fade_duration = request.fade_duration;
1875 let content_id = request.content_id;
1876 let current_focus = self.focused;
1877 let id = self.overlay_manager.show(request);
1878 self.a11y_dirty = true;
1879 if let Some(focus_id) = current_focus {
1880 self.overlay_manager.set_top_focus_restore(focus_id);
1881 }
1882 if let Some(duration) = fade_duration {
1883 self.attach_overlay_fade(id, content_id, duration);
1884 }
1885 id
1886 }
1887
1888 pub fn show_overlay_for(
1889 &mut self,
1890 request: crate::overlay::OverlayRequest,
1891 duration: std::time::Duration,
1892 ) -> crate::overlay::OverlayId {
1893 let fade_duration = request.fade_duration;
1894 let content_id = request.content_id;
1895 let id = self.overlay_manager.show_for(request, duration);
1896 self.overlay_manager.set_shown_at_sim(id, self.sim_clock);
1897 self.a11y_dirty = true;
1898 if let Some(fade) = fade_duration {
1899 self.attach_overlay_fade(id, content_id, fade);
1900 }
1901 id
1902 }
1903
1904 fn attach_overlay_fade(
1912 &mut self,
1913 overlay_id: crate::overlay::OverlayId,
1914 content_id: WidgetId,
1915 duration: std::time::Duration,
1916 ) {
1917 let opacity = crate::signal::Signal::<f32>::new_animated(0.0);
1918 self.register_animated_signal(&opacity, content_id);
1919 self.set_opacity(content_id, opacity.clone());
1920 if self.prefers_reduced_motion() {
1924 opacity.set(1.0);
1925 self.overlay_manager
1926 .attach_fade(overlay_id, opacity, std::time::Duration::ZERO);
1927 return;
1928 }
1929 let _ = opacity.try_animate_with_options(crate::animation::AnimationRequest {
1930 target: 1.0,
1931 duration,
1932 easing: teksilo_tokens::Easing::EaseOut,
1933 frame_interval: None,
1934 looping: false,
1935 epsilon: 0.0,
1936 max_duration: None,
1937 });
1938 self.overlay_manager
1939 .attach_fade(overlay_id, opacity, duration);
1940 }
1941
1942 pub fn dismiss_overlay(&mut self, id: crate::overlay::OverlayId) {
1947 let mut noop = crate::window::NoopWindowOps;
1948 self.dismiss_overlay_with_ops(id, &mut noop);
1949 }
1950
1951 pub fn dismiss_overlay_with_ops(
1954 &mut self,
1955 id: crate::overlay::OverlayId,
1956 ops: &mut dyn crate::window::WindowOps,
1957 ) {
1958 let dismissed = self.overlay_manager.dismiss(id);
1959 self.dormant_dismissed_content(&dismissed, &mut *ops);
1960 }
1961
1962 pub(super) fn dormant_dismissed_content(
1963 &mut self,
1964 content_ids: &[WidgetId],
1965 ops: &mut dyn crate::window::WindowOps,
1966 ) {
1967 let mut any_tooltip_dismissed = false;
1973 for &id in content_ids {
1974 if let Some(entry) = self.tooltips.iter_mut().find(|e| e.content_id == id) {
1975 entry.suppressed_until_focus_leaves = entry.promoted_by_focus;
1982 entry.overlay_id = None;
1983 entry.is_sticky = false;
1984 entry.hover_start = None;
1985 entry.real_hover_start = None;
1986 entry.hover_origin = None;
1987 entry.shown_at_sim = None;
1988 entry.shown_at_real = None;
1989 entry.promoted_by_focus = false;
1990 entry.armed_by_focus = false;
1991 if let Some(sink) = entry.shown_at_sink.as_ref() {
1992 sink.set(None);
1993 }
1994 any_tooltip_dismissed = true;
1995 }
1996 }
1997 if any_tooltip_dismissed {
1998 let grace = super::TOOLTIP_SESSION_GRACE;
1999 self.tooltip_session_until_sim = Some(self.sim_clock + grace);
2000 self.tooltip_session_until_real = Some(std::time::Instant::now() + grace);
2001 }
2002 for &id in content_ids {
2003 let focused_in_subtree = self
2004 .focused
2005 .filter(|focused| self.is_descendant_of(*focused, id));
2006 let hovered_in_subtree = self
2007 .hovered
2008 .filter(|hovered| self.is_descendant_of(*hovered, id));
2009
2010 if let Some(focused) = focused_in_subtree {
2011 self.dispatch_to_widget(focused, &WidgetEvent::FocusLost, &mut *ops);
2012 if self
2013 .focused
2014 .is_some_and(|current| self.is_descendant_of(current, id))
2015 {
2016 let old = self.focused;
2017 self.set_focused(None);
2018 self.focus_origin = None;
2019 self.update_focus_within_signals(old, None);
2020 self.update_view_focus_signals(old, None);
2021 }
2022 }
2023
2024 self.arena.set_dormant(id);
2025
2026 if hovered_in_subtree.is_some() {
2027 let old = self.hovered;
2028 self.set_hovered(None);
2029 self.update_hover_within_signals(old, None);
2030 }
2031 }
2032 if !content_ids.is_empty() {
2033 self.cached_frame = None;
2034 self.a11y_dirty = true;
2035 }
2036 }
2037
2038 pub fn is_visible(&self, id: WidgetId) -> bool {
2039 self.arena.is_active(id)
2040 }
2041}
2042
2043#[cfg(test)]
2044mod tests {
2045 use super::*;
2046 use crate::test_widgets::FillWidget;
2047
2048 #[test]
2049 fn is_visible_reflects_dormancy() {
2050 let mut tree = WidgetTree::new();
2051 let widget = tree.add(FillWidget::new());
2052 tree.layout(SizeProposal::exact(100.0, 50.0));
2053
2054 assert!(tree.is_visible(widget));
2055 tree.set_dormant(widget);
2056 assert!(!tree.is_visible(widget));
2057 tree.activate(widget);
2058 assert!(tree.is_visible(widget));
2059 }
2060
2061 #[test]
2062 fn frame_tick_deadline_caps_per_frame_effects_at_60hz() {
2063 let mut tree = WidgetTree::new();
2064 assert!(tree.frame_tick_deadline().is_none());
2066
2067 tree.request_frame();
2070 let t0 = std::time::Instant::now();
2072 tree.last_frame_time = Some(t0);
2073
2074 let deadline = tree
2075 .frame_tick_deadline()
2076 .expect("an armed per-frame effect must publish a deadline");
2077 let interval = deadline.saturating_duration_since(t0);
2078 assert!(
2081 interval >= std::time::Duration::from_micros(16_000)
2082 && interval <= std::time::Duration::from_micros(17_500),
2083 "per-frame effects must pace at ~60 Hz (got {interval:?})"
2084 );
2085
2086 assert_eq!(
2089 tree.next_timer_deadline(),
2090 Some(deadline),
2091 "frame-tick deadline must be surfaced by next_timer_deadline"
2092 );
2093 }
2094
2095 #[test]
2096 fn throttled_subscriber_stretches_deadline_but_per_frame_wins_min() {
2097 use crate::test_widgets::FillWidget;
2098 let mut tree = WidgetTree::new();
2099 let w = tree.add(FillWidget::new());
2100 let _throttled =
2102 tree.subscribe_frame_tick_throttled(w, std::time::Duration::from_millis(1500));
2103 tree.request_frame();
2104 let t0 = std::time::Instant::now();
2105 tree.last_frame_time = Some(t0);
2106
2107 let d = tree.frame_tick_deadline().expect("armed");
2110 let dt = d.saturating_duration_since(t0);
2111 assert!(
2112 dt >= std::time::Duration::from_millis(1490)
2113 && dt <= std::time::Duration::from_millis(1510),
2114 "a lone throttled subscriber must pace at its interval (~1.5 s), got {dt:?}"
2115 );
2116
2117 let w2 = tree.add(FillWidget::new());
2121 let _per_frame = tree.subscribe_frame_tick(w2);
2122 let d2 = tree.frame_tick_deadline().expect("armed");
2123 let dt2 = d2.saturating_duration_since(t0);
2124 assert!(
2125 dt2 <= std::time::Duration::from_millis(20),
2126 "a per-frame subscriber must pull the shared deadline to 60 Hz, got {dt2:?}"
2127 );
2128 }
2129
2130 #[test]
2131 fn show_and_dismiss_overlay() {
2132 let mut tree = WidgetTree::new();
2133 let anchor = tree.add(FillWidget::new());
2134 let content = tree.add(FillWidget::new().label("Overlay"));
2135 tree.layout(SizeProposal::exact(200.0, 100.0));
2136
2137 assert!(tree.active_overlays().is_empty());
2138
2139 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2140 content_id: content,
2141 anchor,
2142 placement: crate::overlay::OverlayPlacement::Below,
2143 dismiss: crate::overlay::DismissBehavior::Manual,
2144 layer: crate::overlay::OverlayLayer::InTree,
2145 parent_overlay: None,
2146 on_dismiss: None,
2147 fade_duration: None,
2148 });
2149
2150 assert_eq!(tree.active_overlays().len(), 1);
2151
2152 tree.dismiss_overlay(id);
2153 assert!(tree.active_overlays().is_empty());
2154 assert!(!tree.is_visible(content));
2155 }
2156
2157 #[test]
2162 fn modal_centered_overlay_is_a_host_surface() {
2163 let mut tree = WidgetTree::new();
2164 let anchor = tree.add(FillWidget::new());
2165 let modal_content = tree.add(FillWidget::new().label("Modal"));
2166 let dropdown_content = tree.add(FillWidget::new().label("Dropdown"));
2167 tree.layout(SizeProposal::exact(200.0, 100.0));
2168
2169 let modal = tree.show_overlay(crate::overlay::OverlayRequest {
2170 content_id: modal_content,
2171 anchor,
2172 placement: crate::overlay::OverlayPlacement::Centered,
2173 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2174 layer: crate::overlay::OverlayLayer::InTree,
2175 parent_overlay: None,
2176 on_dismiss: None,
2177 fade_duration: None,
2178 });
2179 let dropdown = tree.show_overlay(crate::overlay::OverlayRequest {
2180 content_id: dropdown_content,
2181 anchor,
2182 placement: crate::overlay::OverlayPlacement::Below,
2183 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2184 layer: crate::overlay::OverlayLayer::InTree,
2185 parent_overlay: Some(modal),
2186 on_dismiss: None,
2187 fade_duration: None,
2188 });
2189
2190 assert!(
2191 tree.overlay_is_host_surface(modal),
2192 "a Centered modal overlay must be treated as a host surface"
2193 );
2194 assert!(
2195 !tree.overlay_is_host_surface(dropdown),
2196 "a plain dropdown overlay is not a host surface"
2197 );
2198 }
2199
2200 #[test]
2201 fn escape_dismisses_topmost_overlay() {
2202 let mut tree = WidgetTree::new();
2203 let anchor = tree.add(FillWidget::new().focusable());
2204 let content = tree.add(FillWidget::new());
2205 tree.layout(SizeProposal::exact(200.0, 100.0));
2206 tree.focus(anchor);
2207
2208 tree.show_overlay(crate::overlay::OverlayRequest {
2209 content_id: content,
2210 anchor,
2211 placement: crate::overlay::OverlayPlacement::Below,
2212 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2213 layer: crate::overlay::OverlayLayer::InTree,
2214 parent_overlay: None,
2215 on_dismiss: None,
2216 fade_duration: None,
2217 });
2218
2219 assert_eq!(tree.active_overlays().len(), 1);
2220
2221 tree.press_key(Key::Escape, Modifiers::NONE);
2222 assert!(tree.active_overlays().is_empty());
2223 assert!(!tree.is_visible(content));
2224 }
2225
2226 fn show_two_nested_overlays(tree: &mut WidgetTree) -> (WidgetId, WidgetId) {
2229 let anchor = tree.add(FillWidget::new());
2230 let c1 = tree.add(FillWidget::new());
2231 let c2 = tree.add(FillWidget::new());
2232 tree.layout(SizeProposal::exact(200.0, 100.0));
2233 let o1 = tree.show_overlay(crate::overlay::OverlayRequest {
2234 content_id: c1,
2235 anchor,
2236 placement: crate::overlay::OverlayPlacement::Below,
2237 dismiss: crate::overlay::DismissBehavior::Manual,
2238 layer: crate::overlay::OverlayLayer::InTree,
2239 parent_overlay: None,
2240 on_dismiss: None,
2241 fade_duration: None,
2242 });
2243 tree.show_overlay(crate::overlay::OverlayRequest {
2244 content_id: c2,
2245 anchor: c1,
2246 placement: crate::overlay::OverlayPlacement::Below,
2247 dismiss: crate::overlay::DismissBehavior::Manual,
2248 layer: crate::overlay::OverlayLayer::InTree,
2249 parent_overlay: Some(o1),
2250 on_dismiss: None,
2251 fade_duration: None,
2252 });
2253 (c1, c2)
2254 }
2255
2256 #[test]
2257 fn nested_overlay_back_key_dismisses_with_arrow_left_under_ltr() {
2258 let mut tree = WidgetTree::new();
2259 let _ = show_two_nested_overlays(&mut tree);
2260 assert_eq!(tree.active_overlays().len(), 2);
2261
2262 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2264 assert_eq!(tree.active_overlays().len(), 2);
2265
2266 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2268 assert_eq!(tree.active_overlays().len(), 1);
2269 }
2270
2271 #[test]
2272 fn nested_overlay_back_key_flips_to_arrow_right_under_rtl() {
2273 let mut tree = WidgetTree::new();
2274 tree.set_layout_direction(crate::environment::LayoutDirection::RightToLeft);
2275 let _ = show_two_nested_overlays(&mut tree);
2276 assert_eq!(tree.active_overlays().len(), 2);
2277
2278 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2281 assert_eq!(tree.active_overlays().len(), 2);
2282
2283 tree.press_key(Key::ArrowRight, Modifiers::NONE);
2285 assert_eq!(tree.active_overlays().len(), 1);
2286 }
2287
2288 #[test]
2293 fn back_key_does_not_dismiss_a_dialog_on_top_of_a_modal() {
2294 let mut tree = WidgetTree::new();
2295 let anchor = tree.add(FillWidget::new());
2296 let scrim1 = tree.add(FillWidget::new());
2297 let scrim2 = tree.add(FillWidget::new());
2298 let dialog = tree.add(FillWidget::new());
2299 tree.layout(SizeProposal::exact(200.0, 100.0));
2300 for c in [scrim1, scrim2] {
2302 tree.show_overlay(crate::overlay::OverlayRequest {
2303 content_id: c,
2304 anchor,
2305 placement: crate::overlay::OverlayPlacement::Below,
2306 dismiss: crate::overlay::DismissBehavior::Manual,
2307 layer: crate::overlay::OverlayLayer::InTree,
2308 parent_overlay: None,
2309 on_dismiss: None,
2310 fade_duration: None,
2311 });
2312 }
2313 tree.show_overlay(crate::overlay::OverlayRequest {
2315 content_id: dialog,
2316 anchor,
2317 placement: crate::overlay::OverlayPlacement::Centered,
2318 dismiss: crate::overlay::DismissBehavior::Manual,
2319 layer: crate::overlay::OverlayLayer::InTree,
2320 parent_overlay: None,
2321 on_dismiss: None,
2322 fade_duration: None,
2323 });
2324 assert_eq!(tree.active_overlays().len(), 3);
2325
2326 tree.press_key(Key::ArrowLeft, Modifiers::NONE);
2329 assert_eq!(
2330 tree.active_overlays().len(),
2331 3,
2332 "the back key must not close a dialog sitting on top of a modal"
2333 );
2334 }
2335
2336 #[test]
2337 fn escape_does_not_dismiss_manual_overlay() {
2338 let mut tree = WidgetTree::new();
2339 let anchor = tree.add(FillWidget::new().focusable());
2340 let content = tree.add(FillWidget::new());
2341 tree.layout(SizeProposal::exact(200.0, 100.0));
2342 tree.focus(anchor);
2343
2344 tree.show_overlay(crate::overlay::OverlayRequest {
2345 content_id: content,
2346 anchor,
2347 placement: crate::overlay::OverlayPlacement::Below,
2348 dismiss: crate::overlay::DismissBehavior::Manual,
2349 layer: crate::overlay::OverlayLayer::InTree,
2350 parent_overlay: None,
2351 on_dismiss: None,
2352 fade_duration: None,
2353 });
2354
2355 assert_eq!(tree.active_overlays().len(), 1);
2356
2357 tree.press_key(Key::Escape, Modifiers::NONE);
2358 assert_eq!(tree.active_overlays().len(), 1);
2360 }
2361
2362 #[test]
2363 fn click_outside_dismisses_overlay() {
2364 let mut tree = WidgetTree::new();
2365 let anchor = tree.add(FillWidget::new());
2366 let content = tree.add(FillWidget::new());
2367 tree.layout(SizeProposal::exact(200.0, 100.0));
2368
2369 let overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2370 content_id: content,
2371 anchor,
2372 placement: crate::overlay::OverlayPlacement::Below,
2373 dismiss: crate::overlay::DismissBehavior::ClickOutside,
2374 layer: crate::overlay::OverlayLayer::InTree,
2375 parent_overlay: None,
2376 on_dismiss: None,
2377 fade_duration: None,
2378 });
2379
2380 tree.overlay_manager
2381 .set_content_bounds(overlay, teksilo_canvas::Size::new(100.0, 50.0));
2382
2383 assert_eq!(tree.active_overlays().len(), 1);
2384
2385 tree.dispatch_event(WidgetEvent::PointerDown {
2386 position: Point::new(500.0, 500.0),
2387 button: PointerButton::Primary,
2388 modifiers: Modifiers::NONE,
2389 });
2390 assert!(tree.active_overlays().is_empty());
2391 assert!(!tree.is_visible(content));
2392 }
2393
2394 #[test]
2395 fn cascade_dismissal() {
2396 let mut tree = WidgetTree::new();
2397 let anchor = tree.add(FillWidget::new());
2398 let content_a = tree.add(FillWidget::new());
2399 let content_b = tree.add(FillWidget::new());
2400 tree.layout(SizeProposal::exact(200.0, 100.0));
2401
2402 let parent = tree.show_overlay(crate::overlay::OverlayRequest {
2403 content_id: content_a,
2404 anchor,
2405 placement: crate::overlay::OverlayPlacement::Below,
2406 dismiss: crate::overlay::DismissBehavior::Manual,
2407 layer: crate::overlay::OverlayLayer::InTree,
2408 parent_overlay: None,
2409 on_dismiss: None,
2410 fade_duration: None,
2411 });
2412 tree.show_overlay(crate::overlay::OverlayRequest {
2413 content_id: content_b,
2414 anchor: content_a,
2415 placement: crate::overlay::OverlayPlacement::TrailingEdge,
2416 dismiss: crate::overlay::DismissBehavior::Manual,
2417 layer: crate::overlay::OverlayLayer::InTree,
2418 parent_overlay: Some(parent),
2419 on_dismiss: None,
2420 fade_duration: None,
2421 });
2422
2423 assert_eq!(tree.active_overlays().len(), 2);
2424
2425 tree.dismiss_overlay(parent);
2426 assert!(tree.active_overlays().is_empty());
2427 assert!(!tree.is_visible(content_a));
2428 assert!(!tree.is_visible(content_b));
2429 }
2430
2431 #[test]
2432 fn dismissed_overlay_content_is_dormant_and_invisible() {
2433 let mut tree = WidgetTree::new();
2434 let anchor = tree.add(FillWidget::new());
2435 let content = tree.add(FillWidget::new());
2436 tree.layout(SizeProposal::exact(800.0, 600.0));
2437
2438 let id = tree.show_overlay(crate::overlay::OverlayRequest {
2439 content_id: content,
2440 anchor,
2441 placement: crate::overlay::OverlayPlacement::Below,
2442 dismiss: crate::overlay::DismissBehavior::Manual,
2443 layer: crate::overlay::OverlayLayer::InTree,
2444 parent_overlay: None,
2445 on_dismiss: None,
2446 fade_duration: None,
2447 });
2448
2449 tree.layout(SizeProposal::exact(800.0, 600.0));
2450
2451 tree.dismiss_overlay(id);
2452 assert!(!tree.is_visible(content));
2453
2454 tree.layout(SizeProposal::exact(800.0, 600.0));
2455
2456 let center = tree.bounds(content).center();
2457 let hit = tree.hit_test(center);
2458 assert_ne!(hit, Some(content));
2459
2460 let _frame = tree.render();
2461 assert!(!tree.is_visible(content));
2462 }
2463
2464 #[test]
2465 fn tooltip_appears_after_delay() {
2466 let mut tree = WidgetTree::new();
2467 let anchor = tree.add(FillWidget::new());
2468 let tooltip = tree.add(FillWidget::new().label("Tooltip text"));
2469 tree.layout(SizeProposal::exact(200.0, 100.0));
2470
2471 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2472
2473 let center = tree.bounds(anchor).center();
2474 tree.pointer_move(center);
2475 assert!(tree.active_overlays().is_empty());
2476
2477 tree.advance_time(std::time::Duration::from_millis(600));
2478
2479 assert_eq!(tree.active_overlays().len(), 1);
2480 assert!(tree.find_by_label("Tooltip text").is_some());
2481 }
2482
2483 #[test]
2484 fn tooltip_survives_theme_switch() {
2485 let mut tree = WidgetTree::new().with_theme(crate::presets::intui::light());
2490 let anchor = tree.add(FillWidget::new());
2491 let tooltip = tree.add(FillWidget::new().label("Tip"));
2492 tree.layout(SizeProposal::exact(200.0, 100.0));
2493
2494 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2495
2496 tree.set_theme(crate::presets::intui::dark());
2497
2498 tree.pointer_move(tree.bounds(anchor).center());
2499 tree.advance_time(std::time::Duration::from_millis(600));
2500
2501 assert_eq!(tree.active_overlays().len(), 1);
2502 assert!(tree.find_by_label("Tip").is_some());
2503 }
2504
2505 #[test]
2506 fn tooltip_suppressed_when_hovering_anchor_owned_overlay_content() {
2507 let mut tree = WidgetTree::new();
2515 let anchor = tree.add(FillWidget::new());
2516 let panel = tree.add_child(anchor, FillWidget::new());
2519 let row = tree.add_child(panel, FillWidget::new());
2520 let tip = tree.add(FillWidget::new().label("Tip"));
2521 tree.layout(SizeProposal::exact(200.0, 100.0));
2522
2523 let delay = std::time::Duration::from_millis(100);
2524 tree.attach_tooltip(anchor, tip, delay);
2525
2526 tree.tooltip_pointer_enter(anchor);
2529 tree.advance_time(delay + std::time::Duration::from_millis(50));
2530 assert_eq!(
2531 tree.active_overlays().len(),
2532 1,
2533 "tooltip should appear when hovering the anchor itself"
2534 );
2535 tree.pointer_move(Point::new(500.0, 500.0));
2538 tree.advance_time(std::time::Duration::from_millis(150));
2539 assert!(tree.active_overlays().is_empty());
2540
2541 tree.show_overlay(crate::overlay::OverlayRequest {
2543 content_id: panel,
2544 anchor,
2545 placement: crate::overlay::OverlayPlacement::Below,
2546 dismiss: crate::overlay::DismissBehavior::Manual,
2547 layer: crate::overlay::OverlayLayer::InTree,
2548 parent_overlay: None,
2549 on_dismiss: None,
2550 fade_duration: None,
2551 });
2552 assert_eq!(tree.active_overlays().len(), 1);
2553
2554 tree.tooltip_pointer_enter(row);
2557 tree.advance_time(delay + std::time::Duration::from_millis(50));
2558 assert_eq!(
2559 tree.active_overlays().len(),
2560 1,
2561 "anchor tooltip must not leak onto its own overlay's rows"
2562 );
2563 }
2564
2565 #[test]
2566 fn tooltip_inside_overlay_still_fires() {
2567 let mut tree = WidgetTree::new();
2571 let host = tree.add(FillWidget::new());
2572 let panel = tree.add_child(host, FillWidget::new());
2573 let row = tree.add_child(panel, FillWidget::new());
2574 let tip = tree.add(FillWidget::new().label("Row tip"));
2575 tree.layout(SizeProposal::exact(200.0, 100.0));
2576
2577 let delay = std::time::Duration::from_millis(100);
2578 tree.attach_tooltip(row, tip, delay);
2580
2581 tree.show_overlay(crate::overlay::OverlayRequest {
2582 content_id: panel,
2583 anchor: host,
2584 placement: crate::overlay::OverlayPlacement::Below,
2585 dismiss: crate::overlay::DismissBehavior::Manual,
2586 layer: crate::overlay::OverlayLayer::InTree,
2587 parent_overlay: None,
2588 on_dismiss: None,
2589 fade_duration: None,
2590 });
2591 assert_eq!(tree.active_overlays().len(), 1);
2592
2593 tree.tooltip_pointer_enter(row);
2594 tree.advance_time(delay + std::time::Duration::from_millis(50));
2595 assert_eq!(
2596 tree.active_overlays().len(),
2597 2,
2598 "a row's own tooltip should still fire inside an overlay"
2599 );
2600 }
2601
2602 #[test]
2603 fn tooltip_dismissed_on_pointer_leave() {
2604 let mut tree = WidgetTree::new();
2605 let anchor = tree.add(FillWidget::new());
2606 let tooltip = tree.add(FillWidget::new().label("Tip"));
2607 tree.layout(SizeProposal::exact(200.0, 100.0));
2608
2609 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2610
2611 tree.pointer_move(tree.bounds(anchor).center());
2612 tree.advance_time(std::time::Duration::from_millis(600));
2613 assert_eq!(tree.active_overlays().len(), 1);
2614
2615 tree.pointer_move(Point::new(500.0, 500.0));
2620 assert_eq!(
2621 tree.active_overlays().len(),
2622 1,
2623 "tooltip persists briefly after anchor-leave (hoverable grace)"
2624 );
2625 tree.advance_time(std::time::Duration::from_millis(150));
2626 assert!(
2627 tree.active_overlays().is_empty(),
2628 "tooltip dismissed after the 100ms grace outside anchor+overlay"
2629 );
2630 }
2631
2632 #[test]
2633 fn tooltip_not_shown_if_pointer_leaves_before_delay() {
2634 let mut tree = WidgetTree::new();
2635 let anchor = tree.add(FillWidget::new());
2636 let tooltip = tree.add(FillWidget::new().label("Tip"));
2637 tree.layout(SizeProposal::exact(200.0, 100.0));
2638
2639 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
2640
2641 tree.pointer_move(tree.bounds(anchor).center());
2642 tree.advance_time(std::time::Duration::from_millis(200));
2643 tree.pointer_move(Point::new(500.0, 500.0));
2644
2645 tree.advance_time(std::time::Duration::from_millis(500));
2646 assert!(tree.active_overlays().is_empty());
2647 }
2648
2649 #[test]
2650 fn tooltip_reshow_uses_short_delay_after_prior_tip() {
2651 let mut tree = WidgetTree::new();
2654 let a = tree.add(FillWidget::new());
2655 let b = tree.add(FillWidget::new());
2656 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2657 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2658 tree.layout(SizeProposal::exact(400.0, 200.0));
2659
2660 let delay = std::time::Duration::from_millis(500);
2661 tree.attach_tooltip(a, tip_a, delay);
2662 tree.attach_tooltip(b, tip_b, delay);
2663
2664 tree.tooltip_pointer_enter(a);
2667 tree.advance_time(std::time::Duration::from_millis(150));
2668 assert!(
2669 tree.active_overlays().is_empty(),
2670 "must not appear before full initial delay"
2671 );
2672 tree.advance_time(std::time::Duration::from_millis(400));
2673 assert_eq!(
2674 tree.active_overlays().len(),
2675 1,
2676 "first tip after initial delay"
2677 );
2678 assert!(tree.find_by_label("Tip A").is_some());
2679
2680 let mut noop = crate::window::NoopWindowOps;
2682 tree.tooltip_pointer_leave(a, &mut noop);
2683 tree.tooltip_pointer_enter(b);
2684 tree.advance_time(std::time::Duration::from_millis(120));
2685 assert!(
2686 tree.find_by_label("Tip B").is_some(),
2687 "second tip should use the short reshow delay while session is warm"
2688 );
2689 }
2690
2691 #[test]
2692 fn reshow_delay_reverts_to_full_after_the_session_grace_expires() {
2693 let mut tree = WidgetTree::new();
2699 tree.set_accessibility_preferences(false, true, 1.0);
2703 let a = tree.add(FillWidget::new());
2704 let b = tree.add(FillWidget::new());
2705 let tip_a = tree.add(FillWidget::new().label("Tip A"));
2706 let tip_b = tree.add(FillWidget::new().label("Tip B"));
2707 tree.layout(SizeProposal::exact(400.0, 200.0));
2708
2709 let delay = std::time::Duration::from_millis(500);
2710 tree.attach_tooltip(a, tip_a, delay);
2711 tree.attach_tooltip(b, tip_b, delay);
2712
2713 tree.tooltip_pointer_enter(a);
2715 tree.advance_time(std::time::Duration::from_millis(550));
2716 assert!(tree.find_by_label("Tip A").is_some(), "first tip shown");
2717
2718 tree.pointer_move(Point::new(900.0, 900.0));
2719 tree.advance_time(std::time::Duration::from_millis(150));
2720 assert!(tree.active_overlays().is_empty(), "A dismissed on leave");
2721
2722 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2724
2725 tree.tooltip_pointer_enter(b);
2726 tree.advance_time(std::time::Duration::from_millis(150));
2727 assert!(
2728 tree.active_overlays().is_empty(),
2729 "session went cold — B must pay the FULL delay, not the 100 ms reshow"
2730 );
2731 tree.advance_time(std::time::Duration::from_millis(400));
2732 assert!(
2733 tree.find_by_label("Tip B").is_some(),
2734 "B still appears once its full delay elapses"
2735 );
2736 }
2737
2738 #[test]
2739 fn warm_reshow_scales_the_delay_rather_than_flattening_every_tier() {
2740 let mut tree = WidgetTree::new();
2746 let light = tree.add(FillWidget::new());
2747 let heavy = tree.add(FillWidget::new());
2748 let tip_light = tree.add(FillWidget::new().label("Light"));
2749 let tip_heavy = tree.add(FillWidget::new().label("Heavy"));
2750 tree.layout(SizeProposal::exact(400.0, 200.0));
2751
2752 tree.attach_tooltip(light, tip_light, std::time::Duration::from_millis(500));
2753 tree.attach_tooltip(heavy, tip_heavy, std::time::Duration::from_millis(700));
2754
2755 tree.tooltip_pointer_enter(light);
2757 tree.advance_time(std::time::Duration::from_millis(550));
2758 assert!(tree.find_by_label("Light").is_some(), "session warm");
2759
2760 let mut noop = crate::window::NoopWindowOps;
2761 tree.tooltip_pointer_leave(light, &mut noop);
2762 tree.tooltip_pointer_enter(heavy);
2763
2764 tree.advance_time(std::time::Duration::from_millis(120));
2766 assert!(
2767 tree.find_by_label("Heavy").is_none(),
2768 "a heavy tooltip must not fire at the light tier's reshow delay"
2769 );
2770 tree.advance_time(std::time::Duration::from_millis(40));
2772 assert!(
2773 tree.find_by_label("Heavy").is_some(),
2774 "heavy reshow is the scaled 140 ms"
2775 );
2776 }
2777
2778 #[test]
2779 fn a_pinned_sticky_tooltip_does_not_hold_the_session_warm() {
2780 let mut tree = WidgetTree::new();
2784 tree.set_accessibility_preferences(false, true, 1.0); let a = tree.add(FillWidget::new());
2786 let b = tree.add(FillWidget::new());
2787 let tip_a = tree.add(FillWidget::new().label("Pinned"));
2788 let tip_b = tree.add(FillWidget::new().label("Other"));
2789 tree.layout(SizeProposal::exact(400.0, 200.0));
2790
2791 tree.attach_tooltip_with_sticky(
2792 a,
2793 tip_a,
2794 std::time::Duration::from_millis(500),
2795 Some(std::time::Duration::from_millis(100)),
2796 );
2797 tree.attach_tooltip(b, tip_b, std::time::Duration::from_millis(500));
2798
2799 tree.tooltip_pointer_enter(a);
2800 tree.advance_time(std::time::Duration::from_millis(550));
2801 assert!(tree.find_by_label("Pinned").is_some());
2802 tree.promote_tooltip_to_sticky(tip_a);
2803
2804 tree.advance_time(super::TOOLTIP_SESSION_GRACE + std::time::Duration::from_millis(50));
2806 tree.tooltip_pointer_enter(b);
2807 tree.advance_time(std::time::Duration::from_millis(150));
2808 assert!(
2809 tree.find_by_label("Other").is_none(),
2810 "a pinned sticky must not keep every other anchor on the reshow path"
2811 );
2812 }
2813
2814 #[test]
2815 fn reattaching_a_tooltip_does_not_grow_the_entry_table() {
2816 let mut tree = WidgetTree::new();
2822 let anchor = tree.add(FillWidget::new());
2823 tree.layout(SizeProposal::exact(200.0, 100.0));
2824
2825 let delay = std::time::Duration::from_millis(100);
2826 for _ in 0..25 {
2827 let tip = tree.add(FillWidget::new().label("Tip"));
2829 tree.attach_tooltip(anchor, tip, delay);
2830 assert_eq!(
2831 tree.tooltip_entry_count(),
2832 1,
2833 "an anchor must own exactly one tooltip entry across rebuilds"
2834 );
2835 }
2836
2837 tree.tooltip_pointer_enter(anchor);
2839 tree.advance_time(delay + std::time::Duration::from_millis(50));
2840 assert_eq!(
2841 tree.active_overlays().len(),
2842 1,
2843 "latest tooltip still shows"
2844 );
2845 }
2846
2847 #[test]
2848 fn destroying_an_anchor_reaps_its_tooltip_entry() {
2849 let mut tree = WidgetTree::new();
2852 let host = tree.add(FillWidget::new());
2853 let anchor = tree.add_child(host, FillWidget::new());
2854 let tip = tree.add(FillWidget::new().label("Tip"));
2855 tree.layout(SizeProposal::exact(200.0, 100.0));
2856
2857 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2858 assert_eq!(tree.tooltip_entry_count(), 1);
2859
2860 tree.destroy_subtree(anchor);
2861 assert_eq!(
2862 tree.tooltip_entry_count(),
2863 0,
2864 "destroying the anchor must reap its entry and content node"
2865 );
2866 }
2867
2868 #[test]
2869 fn a_leaving_tooltip_schedules_a_wake_for_its_dismissal() {
2870 let mut tree = WidgetTree::new();
2875 tree.set_accessibility_preferences(false, true, 1.0); let anchor = tree.add(FillWidget::new());
2877 let tip = tree.add(FillWidget::new().label("Tip"));
2878 tree.layout(SizeProposal::exact(400.0, 200.0));
2879
2880 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2881 tree.pointer_move(tree.bounds(anchor).center());
2882 tree.advance_time(std::time::Duration::from_millis(150));
2883 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown");
2884
2885 assert!(
2887 tree.next_timer_deadline().is_none(),
2888 "a settled tooltip under the pointer schedules nothing"
2889 );
2890
2891 tree.pointer_move(Point::new(900.0, 900.0));
2894 assert!(
2895 tree.next_timer_deadline().is_some(),
2896 "the PointerLeave grace must contribute a wake deadline"
2897 );
2898 }
2899
2900 #[test]
2901 fn pressing_cancels_a_pending_dwell_and_dismisses_a_shown_tooltip() {
2902 let mut tree = WidgetTree::new();
2903 let anchor = tree.add(FillWidget::new());
2904 let tip = tree.add(FillWidget::new().label("Tip"));
2905 tree.layout(SizeProposal::exact(400.0, 200.0));
2906
2907 let delay = std::time::Duration::from_millis(500);
2908 tree.attach_tooltip(anchor, tip, delay);
2909
2910 tree.pointer_move(tree.bounds(anchor).center());
2913 tree.advance_time(std::time::Duration::from_millis(300));
2914 tree.tooltip_pointer_press(None);
2915 tree.advance_time(std::time::Duration::from_millis(400));
2916 assert!(
2917 tree.active_overlays().is_empty(),
2918 "a press must cancel the pending dwell, not merely delay it"
2919 );
2920
2921 tree.tooltip_pointer_enter(anchor);
2924 tree.advance_time(delay + std::time::Duration::from_millis(50));
2925 assert_eq!(tree.active_overlays().len(), 1, "tooltip shown again");
2926 tree.tooltip_pointer_press(Some(tree.bounds(anchor).center()));
2927 assert!(
2928 tree.active_overlays().is_empty(),
2929 "a press must dismiss the shown tooltip"
2930 );
2931 }
2932
2933 #[test]
2934 fn window_deactivation_retires_hover_tooltips() {
2935 let mut tree = WidgetTree::new();
2936 let anchor = tree.add(FillWidget::new());
2937 let tip = tree.add(FillWidget::new().label("Tip"));
2938 tree.layout(SizeProposal::exact(400.0, 200.0));
2939
2940 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
2941 tree.pointer_move(tree.bounds(anchor).center());
2942 tree.advance_time(std::time::Duration::from_millis(150));
2943 assert_eq!(tree.active_overlays().len(), 1);
2944
2945 tree.set_window_active(false);
2946 assert!(
2947 tree.active_overlays().is_empty(),
2948 "a tooltip must not float over another window's chrome"
2949 );
2950 }
2951
2952 #[test]
2953 fn only_the_innermost_anchor_arms_its_dwell() {
2954 let mut tree = WidgetTree::new();
2957 let panel = tree.add(FillWidget::new());
2958 let row = tree.add_child(panel, FillWidget::new());
2959 let panel_tip = tree.add(FillWidget::new().label("Panel"));
2960 let row_tip = tree.add(FillWidget::new().label("Row"));
2961 tree.layout(SizeProposal::exact(400.0, 200.0));
2962
2963 let delay = std::time::Duration::from_millis(100);
2964 tree.attach_tooltip(panel, panel_tip, delay);
2965 tree.attach_tooltip(row, row_tip, delay);
2966
2967 tree.tooltip_pointer_enter(row);
2968 tree.advance_time(delay + std::time::Duration::from_millis(50));
2969
2970 assert_eq!(
2971 tree.active_overlays().len(),
2972 1,
2973 "exactly one tooltip may open for a hover"
2974 );
2975 assert!(
2976 tree.find_by_label("Row").is_some(),
2977 "the innermost anchor wins"
2978 );
2979 }
2980
2981 #[test]
2982 fn escape_dismisses_a_hover_tooltip_and_falls_through_to_the_menu_below() {
2983 let mut tree = WidgetTree::new();
2987 tree.set_accessibility_preferences(false, true, 1.0); let anchor = tree.add(FillWidget::new());
2989 let menu = tree.add(FillWidget::new());
2990 let tip = tree.add(FillWidget::new().label("Tip"));
2991 tree.layout(SizeProposal::exact(400.0, 200.0));
2992
2993 let menu_overlay = tree.show_overlay(crate::overlay::OverlayRequest {
2994 content_id: menu,
2995 anchor,
2996 placement: crate::overlay::OverlayPlacement::Below,
2997 dismiss: crate::overlay::DismissBehavior::EscapeOrClickOutside,
2998 layer: crate::overlay::OverlayLayer::InTree,
2999 parent_overlay: None,
3000 on_dismiss: None,
3001 fade_duration: None,
3002 });
3003 assert_eq!(tree.active_overlays().len(), 1);
3004
3005 tree.attach_tooltip(anchor, tip, std::time::Duration::from_millis(100));
3007 tree.tooltip_pointer_enter(anchor);
3008 tree.advance_time(std::time::Duration::from_millis(150));
3009 assert_eq!(
3010 tree.active_overlays().len(),
3011 2,
3012 "tooltip sits above the menu"
3013 );
3014
3015 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3017 assert!(dismissed.is_some(), "Escape must dismiss the hover tooltip");
3018 assert_eq!(tree.active_overlays().len(), 1);
3019
3020 let dismissed = tree.overlay_manager.try_dismiss_top_on_escape();
3022 assert_eq!(
3023 dismissed.map(|(id, _, _)| id),
3024 Some(menu_overlay),
3025 "Escape must then reach the menu underneath"
3026 );
3027 assert!(tree.active_overlays().is_empty());
3028 }
3029
3030 #[test]
3031 fn tooltip_timer_restarts_when_pointer_keeps_moving() {
3032 let mut tree = WidgetTree::new();
3035 let anchor = tree.add(FillWidget::new());
3036 let tooltip = tree.add(FillWidget::new().label("Tip"));
3037 tree.layout(SizeProposal::exact(200.0, 100.0));
3038
3039 tree.attach_tooltip(anchor, tooltip, std::time::Duration::from_millis(500));
3040
3041 let start = tree.bounds(anchor).center();
3042 tree.pointer_move(start);
3043 tree.advance_time(std::time::Duration::from_millis(300));
3044 tree.pointer_move(Point::new(start.x + 20.0, start.y));
3046 tree.advance_time(std::time::Duration::from_millis(300));
3049 assert!(
3050 tree.active_overlays().is_empty(),
3051 "moving past stationary slop must restart the delay"
3052 );
3053 tree.advance_time(std::time::Duration::from_millis(250));
3054 assert_eq!(
3055 tree.active_overlays().len(),
3056 1,
3057 "tooltip appears after a full delay of stillness"
3058 );
3059 }
3060
3061 #[test]
3062 fn timed_overlay_auto_dismisses_after_duration() {
3063 let mut tree = WidgetTree::new();
3064 let anchor = tree.add(FillWidget::new());
3065 let content = tree.add(FillWidget::new().label("Toast"));
3066 tree.layout(SizeProposal::exact(200.0, 100.0));
3067
3068 tree.show_overlay_for(
3069 crate::overlay::OverlayRequest {
3070 content_id: content,
3071 anchor,
3072 placement: crate::overlay::OverlayPlacement::Below,
3073 dismiss: crate::overlay::DismissBehavior::Manual,
3074 layer: crate::overlay::OverlayLayer::InTree,
3075 parent_overlay: None,
3076 on_dismiss: None,
3077 fade_duration: None,
3078 },
3079 std::time::Duration::from_millis(300),
3080 );
3081
3082 assert_eq!(tree.active_overlays().len(), 1);
3083
3084 tree.advance_time(std::time::Duration::from_millis(200));
3085 assert_eq!(tree.active_overlays().len(), 1);
3086
3087 tree.advance_time(std::time::Duration::from_millis(150));
3088 assert!(tree.active_overlays().is_empty());
3089 assert!(!tree.is_visible(content));
3090 }
3091
3092 #[test]
3093 fn fade_dismiss_keeps_overlay_off_active_list_immediately() {
3094 let mut tree = WidgetTree::new();
3102 let anchor = tree.add(FillWidget::new());
3103 let content = tree.add(FillWidget::new().label("Faded"));
3104 tree.layout(SizeProposal::exact(200.0, 100.0));
3105
3106 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3107 content_id: content,
3108 anchor,
3109 placement: crate::overlay::OverlayPlacement::Below,
3110 dismiss: crate::overlay::DismissBehavior::Manual,
3111 layer: crate::overlay::OverlayLayer::InTree,
3112 parent_overlay: None,
3113 on_dismiss: None,
3114 fade_duration: Some(std::time::Duration::from_millis(100)),
3115 });
3116 assert_eq!(tree.active_overlays().len(), 1);
3117
3118 tree.dismiss_overlay(id);
3119 assert!(tree.active_overlays().is_empty());
3124 }
3125
3126 #[test]
3127 fn fade_dismiss_defers_content_dormancy_until_sim_tween_completes() {
3128 let mut tree = WidgetTree::new();
3134 let anchor = tree.add(FillWidget::new());
3135 let content = tree.add(FillWidget::new().label("Faded"));
3136 tree.layout(SizeProposal::exact(200.0, 100.0));
3137
3138 let id = tree.show_overlay(crate::overlay::OverlayRequest {
3139 content_id: content,
3140 anchor,
3141 placement: crate::overlay::OverlayPlacement::Below,
3142 dismiss: crate::overlay::DismissBehavior::Manual,
3143 layer: crate::overlay::OverlayLayer::InTree,
3144 parent_overlay: None,
3145 on_dismiss: None,
3146 fade_duration: Some(std::time::Duration::from_millis(100)),
3147 });
3148 tree.dismiss_overlay(id);
3149 assert!(
3150 tree.is_visible(content),
3151 "content stays active during fade-out"
3152 );
3153
3154 tree.advance_time(std::time::Duration::from_millis(150));
3155 assert!(
3156 !tree.is_visible(content),
3157 "after sim-time past the tween window, deferred removal fires"
3158 );
3159 }
3160}