1use std::time::Duration;
24
25use teksilo_core::build_context::BuildContext;
26use teksilo_core::overlay::TooltipPlacement;
27use teksilo_core::widget::Widget;
28use teksilo_core::widget_id::WidgetId;
29use teksilo_i18n::LocalizedString;
30
31use crate::tooltip::TooltipWidget;
32use crate::tooltip::composite::CompositeTooltipWidget;
33use crate::tooltip::registry::TooltipContent;
34use crate::tooltip::rich::{DWELL_PROMOTION, RichTooltipWidget};
35
36#[derive(Debug, Clone)]
40pub enum RichTooltipSource {
41 Key(String),
45 Content(TooltipContent),
47}
48
49impl<T: Into<String>> From<T> for RichTooltipSource {
50 fn from(value: T) -> Self {
51 RichTooltipSource::Key(value.into())
52 }
53}
54
55pub fn attach_rich_tooltip(
67 ctx: &mut BuildContext,
68 anchor_id: WidgetId,
69 key: impl Into<String>,
70 delay: Duration,
71) -> WidgetId {
72 attach_rich_tooltip_with_placement(ctx, anchor_id, key, delay, TooltipPlacement::Below)
73}
74
75pub fn attach_rich_tooltip_with_placement(
79 ctx: &mut BuildContext,
80 anchor_id: WidgetId,
81 key: impl Into<String>,
82 delay: Duration,
83 placement: TooltipPlacement,
84) -> WidgetId {
85 let tooltip = RichTooltipWidget::from_key(key);
86 let sink = tooltip.shown_at_sink();
91 let tooltip_id = ctx.add_deferred_on_demand(tooltip);
97 ctx.attach_tooltip_with_sticky_sink_placement(
98 anchor_id,
99 tooltip_id,
100 delay,
101 Some(DWELL_PROMOTION),
102 sink,
103 placement,
104 );
105 tooltip_id
106}
107
108pub fn attach_rich_tooltip_content(
113 ctx: &mut BuildContext,
114 anchor_id: WidgetId,
115 content: TooltipContent,
116 delay: Duration,
117) -> WidgetId {
118 attach_rich_tooltip_content_with_placement(
119 ctx,
120 anchor_id,
121 content,
122 delay,
123 TooltipPlacement::Below,
124 )
125}
126
127pub fn attach_plain_tooltip(
137 ctx: &mut BuildContext,
138 anchor_id: WidgetId,
139 text: impl Into<LocalizedString>,
140 delay: Duration,
141) -> WidgetId {
142 let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
143 ctx.attach_tooltip(anchor_id, tooltip_id, delay);
144 tooltip_id
145}
146
147pub fn attach_plain_tooltip_with_placement(
151 ctx: &mut BuildContext,
152 anchor_id: WidgetId,
153 text: impl Into<LocalizedString>,
154 delay: Duration,
155 placement: TooltipPlacement,
156) -> WidgetId {
157 let tooltip_id = ctx.add_deferred_on_demand(TooltipWidget::new(text));
158 ctx.attach_tooltip_with_placement(anchor_id, tooltip_id, delay, placement);
159 tooltip_id
160}
161
162pub fn attach_rich_tooltip_content_with_placement(
164 ctx: &mut BuildContext,
165 anchor_id: WidgetId,
166 content: TooltipContent,
167 delay: Duration,
168 placement: TooltipPlacement,
169) -> WidgetId {
170 let tooltip = RichTooltipWidget::new(content);
171 let sink = tooltip.shown_at_sink();
172 let tooltip_id = ctx.add_deferred_on_demand(tooltip);
174 ctx.attach_tooltip_with_sticky_sink_placement(
175 anchor_id,
176 tooltip_id,
177 delay,
178 Some(DWELL_PROMOTION),
179 sink,
180 placement,
181 );
182 tooltip_id
183}
184
185pub fn attach_rich_tooltip_source(
191 ctx: &mut BuildContext,
192 anchor_id: WidgetId,
193 source: RichTooltipSource,
194 delay: Duration,
195) -> WidgetId {
196 attach_rich_tooltip_source_with_placement(
197 ctx,
198 anchor_id,
199 source,
200 delay,
201 TooltipPlacement::Below,
202 )
203}
204
205pub fn attach_rich_tooltip_source_with_placement(
209 ctx: &mut BuildContext,
210 anchor_id: WidgetId,
211 source: RichTooltipSource,
212 delay: Duration,
213 placement: TooltipPlacement,
214) -> WidgetId {
215 match source {
216 RichTooltipSource::Key(k) => {
217 attach_rich_tooltip_with_placement(ctx, anchor_id, k, delay, placement)
218 }
219 RichTooltipSource::Content(c) => {
220 attach_rich_tooltip_content_with_placement(ctx, anchor_id, c, delay, placement)
221 }
222 }
223}
224
225pub fn attach_composite_tooltip(
230 ctx: &mut BuildContext,
231 anchor_id: WidgetId,
232 content: impl Widget + 'static,
233 delay: Duration,
234) -> WidgetId {
235 attach_composite_tooltip_boxed(ctx, anchor_id, Box::new(content), delay)
236}
237
238pub fn attach_composite_tooltip_boxed(
243 ctx: &mut BuildContext,
244 anchor_id: WidgetId,
245 content: Box<dyn Widget>,
246 delay: Duration,
247) -> WidgetId {
248 attach_composite_tooltip_boxed_with_placement(
249 ctx,
250 anchor_id,
251 content,
252 delay,
253 TooltipPlacement::Below,
254 )
255}
256
257pub fn attach_composite_tooltip_widget_with_placement(
267 ctx: &mut BuildContext,
268 anchor_id: WidgetId,
269 tooltip: CompositeTooltipWidget,
270 delay: Duration,
271 placement: TooltipPlacement,
272) -> WidgetId {
273 let sticky_after = tooltip.sticky_enabled().then_some(DWELL_PROMOTION);
277 let sink = tooltip.shown_at_sink();
278 let tooltip_id = ctx.add_detached_deferred_on_demand(tooltip);
284 ctx.attach_tooltip_with_sticky_sink_placement(
285 anchor_id,
286 tooltip_id,
287 delay,
288 sticky_after,
289 sink,
290 placement,
291 );
292 tooltip_id
293}
294
295pub fn attach_composite_tooltip_boxed_with_placement(
296 ctx: &mut BuildContext,
297 anchor_id: WidgetId,
298 content: Box<dyn Widget>,
299 delay: Duration,
300 placement: TooltipPlacement,
301) -> WidgetId {
302 attach_composite_tooltip_widget_with_placement(
303 ctx,
304 anchor_id,
305 CompositeTooltipWidget::new().content_boxed(content),
306 delay,
307 placement,
308 )
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use crate::button::Button;
315 use crate::menu_item::MenuItem;
316 use crate::menu_list::MenuList;
317 use crate::primitives::VStack;
318 use crate::tooltip::TooltipWidget;
319 use crate::tooltip::registry::{
320 _reset_tooltip_registry, TooltipContent, install_tooltip_registry,
321 };
322 use std::cell::RefCell;
323 use std::rc::Rc;
324 use teksilo_canvas::{MockTextBackend, SizeProposal};
325 use teksilo_core::event::{Key, Modifiers};
326 use teksilo_core::signal::Signal;
327 use teksilo_core::widget_tree::WidgetTree;
328 use teksilo_i18n::lit;
329
330 fn tree_with_backend() -> WidgetTree {
331 WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
332 }
333
334 #[test]
350 fn a_plain_tooltips_text_lands_on_the_control_it_describes() {
351 fn described(
352 update: &teksilo_core::accesskit::TreeUpdate,
353 id: teksilo_core::WidgetId,
354 ) -> Option<String> {
355 let nid = teksilo_core::accessibility::widget_id_to_node_id(id);
356 update
357 .nodes
358 .iter()
359 .find(|(node_id, _)| *node_id == nid)
360 .and_then(|(_, n)| n.description().map(str::to_owned))
361 }
362
363 let mut tree = tree_with_backend();
365 let button = tree.add(Button::new(lit!("Export")).tooltip(lit!("Save a copy")));
366 tree.layout(SizeProposal::exact(300.0, 40.0));
367 let update = tree.sync_accessibility();
368 assert_eq!(
369 described(&update, button).as_deref(),
370 Some("Save a copy"),
371 "a Button's hint must be on the Button, not on the box inside it"
372 );
373
374 let mut tree = tree_with_backend();
376 let toggle = tree.add(
377 crate::toggle::Toggle::new(teksilo_core::signal::Signal::new(true))
378 .label(lit!("Comments"))
379 .tooltip(lit!("Where a note is attached")),
380 );
381 tree.layout(SizeProposal::exact(300.0, 40.0));
382 let update = tree.sync_accessibility();
383 assert_eq!(
384 described(&update, toggle).as_deref(),
385 Some("Where a note is attached"),
386 "a Toggle's hint must be on the Toggle"
387 );
388
389 let carriers = update
392 .nodes
393 .iter()
394 .filter(|(_, n)| n.description() == Some("Where a note is attached"))
395 .count();
396 assert_eq!(carriers, 1, "exactly one node may carry the hint");
397 }
398
399 #[test]
410 fn an_unhovered_tooltip_body_is_never_built() {
411 #[derive(Debug)]
414 struct Counted {
415 builds: Signal<u32>,
416 }
417
418 impl teksilo_core::widget::Widget for Counted {
419 fn build(
420 &mut self,
421 _ctx: &mut teksilo_core::build_context::BuildContext,
422 ) -> Vec<WidgetId> {
423 self.builds.set(self.builds.get() + 1);
424 Vec::new()
425 }
426
427 fn layout_response(
428 &self,
429 proposal: SizeProposal,
430 _ctx: &teksilo_core::widget::LayoutContext,
431 ) -> teksilo_core::widget::LayoutResponse {
432 proposal.resolve(40.0, 20.0).into()
433 }
434 }
435
436 #[derive(Debug)]
438 struct Anchor {
439 builds: Signal<u32>,
440 anchor_builds: Signal<u32>,
441 root: Option<WidgetId>,
442 }
443
444 impl teksilo_core::widget::Widget for Anchor {
445 fn build(
446 &mut self,
447 ctx: &mut teksilo_core::build_context::BuildContext,
448 ) -> Vec<WidgetId> {
449 self.anchor_builds.set(self.anchor_builds.get() + 1);
450 let root = ctx.add(Button::new(lit!("row")));
451 self.root = Some(root);
452 attach_composite_tooltip(
453 ctx,
454 root,
455 Counted {
456 builds: self.builds.clone(),
457 },
458 Duration::from_millis(10),
459 );
460 vec![root]
461 }
462
463 fn layout_response(
464 &self,
465 proposal: SizeProposal,
466 ctx: &teksilo_core::widget::LayoutContext,
467 ) -> teksilo_core::widget::LayoutResponse {
468 self.root
469 .and_then(|id| ctx.child_size(id, proposal))
470 .unwrap_or(teksilo_canvas::Size::new(0.0, 0.0))
471 .into()
472 }
473 }
474
475 let builds = Signal::new(0);
476 let anchor_builds = Signal::new(0);
477 let mut tree = WidgetTree::new();
478 let id = tree.add(Anchor {
479 builds: builds.clone(),
480 anchor_builds: anchor_builds.clone(),
481 root: None,
482 });
483 tree.layout(SizeProposal::exact(300.0, 40.0));
484 assert_eq!(builds.get(), 0, "a tooltip body was built without a dwell");
485
486 for _ in 0..5 {
489 tree.arena_mark_needs_rebuild_for_testing(id);
490 tree.layout(SizeProposal::exact(300.0, 40.0));
491 }
492 assert!(
493 anchor_builds.get() >= 5,
494 "the anchor must really have rebuilt; got {}",
495 anchor_builds.get()
496 );
497 assert_eq!(
498 builds.get(),
499 0,
500 "the anchor rebuilt {} times and dragged its unhovered tooltip along",
501 anchor_builds.get()
502 );
503 }
504
505 #[test]
517 fn tooltips_attached_to_many_children_in_one_build_stay_on_their_own_rows() {
518 #[derive(Debug)]
521 struct RowPane {
522 rows: Vec<WidgetId>,
523 }
524
525 impl teksilo_core::widget::Widget for RowPane {
526 fn build(
527 &mut self,
528 ctx: &mut teksilo_core::build_context::BuildContext,
529 ) -> Vec<WidgetId> {
530 self.rows.clear();
531 for label in ["Alpha", "Beta"] {
532 let row = ctx.add(Button::new(lit!(String::from(label))));
533 let tip = ctx.add(TooltipWidget::new(lit!(String::from("about ") + label)));
534 ctx.attach_tooltip(row, tip, Duration::from_millis(10));
535 self.rows.push(row);
536 }
537 self.rows.clone()
538 }
539
540 fn layout_response(
541 &self,
542 proposal: teksilo_canvas::SizeProposal,
543 _ctx: &teksilo_core::widget::LayoutContext,
544 ) -> teksilo_core::widget::LayoutResponse {
545 proposal.resolve(200.0, 40.0).into()
546 }
547
548 fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
549 builder.set_role(teksilo_core::accesskit::Role::Group);
553 }
554 }
555
556 let mut tree = tree_with_backend();
557 let pane = tree.add(RowPane { rows: Vec::new() });
558 tree.layout(SizeProposal::exact(200.0, 40.0));
559 let update = tree.sync_accessibility();
560
561 let described: Vec<String> = update
562 .nodes
563 .iter()
564 .filter_map(|(_, n)| n.description().map(str::to_owned))
565 .collect();
566 assert_eq!(
567 described.len(),
568 2,
569 "both rows keep their own hint: {described:?}"
570 );
571 assert!(described.iter().any(|d| d == "about Alpha"));
572 assert!(described.iter().any(|d| d == "about Beta"));
573
574 let pane_node = update
576 .nodes
577 .iter()
578 .find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(pane))
579 .map(|(_, n)| n);
580 assert_eq!(
581 pane_node.and_then(|n| n.description()),
582 None,
583 "a pane that claimed one hint per row must be given none of them"
584 );
585 }
586
587 #[test]
599 fn a_widgets_own_description_is_not_overwritten_by_its_tooltips() {
600 #[derive(Debug)]
601 struct SelfDescribing {
602 inner: Option<WidgetId>,
603 }
604
605 impl teksilo_core::widget::Widget for SelfDescribing {
606 fn build(
607 &mut self,
608 ctx: &mut teksilo_core::build_context::BuildContext,
609 ) -> Vec<WidgetId> {
610 let body = ctx.add(Button::new(lit!("Save")));
611 let tip = ctx.add(TooltipWidget::new(lit!("supplementary")));
612 ctx.attach_tooltip(body, tip, Duration::from_millis(10));
613 self.inner = Some(body);
614 vec![body]
615 }
616
617 fn layout_response(
618 &self,
619 proposal: teksilo_canvas::SizeProposal,
620 _ctx: &teksilo_core::widget::LayoutContext,
621 ) -> teksilo_core::widget::LayoutResponse {
622 proposal.resolve(120.0, 30.0).into()
623 }
624
625 fn accessibility(&self, builder: &mut teksilo_core::accessibility::AccessNodeBuilder) {
626 builder.set_role(teksilo_core::accesskit::Role::Button);
627 builder.set_name("Save");
628 builder.set_description("Ctrl+S");
629 }
630 }
631
632 let mut tree = tree_with_backend();
633 let id = tree.add(SelfDescribing { inner: None });
634 tree.layout(SizeProposal::exact(120.0, 30.0));
635 let update = tree.sync_accessibility();
636
637 let own = update
638 .nodes
639 .iter()
640 .find(|(nid, _)| *nid == teksilo_core::accessibility::widget_id_to_node_id(id))
641 .map(|(_, n)| n)
642 .expect("the control emits a node");
643 assert_eq!(
644 own.description(),
645 Some("Ctrl+S"),
646 "the widget's own description must survive its tooltip"
647 );
648 assert_eq!(
649 update
650 .nodes
651 .iter()
652 .filter(|(_, n)| n.description() == Some("supplementary"))
653 .count(),
654 1,
655 "and the tooltip's text is still emitted, on its anchor as before"
656 );
657 }
658
659 #[test]
669 fn escape_dismisses_a_tooltip_and_still_reaches_the_focused_widget() {
670 use std::cell::Cell;
671 use std::rc::Rc;
672 use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
673 use teksilo_core::widget_builder::WidgetBuilder;
674
675 let seen: Rc<Cell<usize>> = Rc::new(Cell::new(0));
676 let counter = seen.clone();
677
678 let mut tree = tree_with_backend();
679 let btn = tree.add(
680 Button::new(lit!("Save As"))
681 .tooltip(lit!("Save the current file under a new name"))
682 .on_key(move |ev, _ctx| {
683 if let WidgetEvent::KeyDown {
684 key: Key::Escape, ..
685 } = ev
686 {
687 counter.set(counter.get() + 1);
688 return EventResponse::Handled;
689 }
690 EventResponse::Ignored
691 }),
692 );
693 tree.layout(SizeProposal::exact(400.0, 200.0));
694 tree.focus(btn);
695
696 tree.pointer_move(tree.bounds(btn).center());
699 tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
700 assert_eq!(
701 tree.active_overlays().len(),
702 1,
703 "the tooltip should be showing"
704 );
705
706 tree.press_key(Key::Escape, Modifiers::NONE);
707
708 assert!(
709 tree.active_overlays().is_empty(),
710 "Escape must still dismiss the tooltip (WCAG 1.4.13)"
711 );
712 assert_eq!(
713 seen.get(),
714 1,
715 "the focused widget never saw Escape — the tooltip swallowed it"
716 );
717 }
718
719 #[test]
720 fn button_rich_tooltip_appears_after_hover_delay() {
721 _reset_tooltip_registry();
722 install_tooltip_registry(vec![TooltipContent::new(
723 "save-as",
724 lit!("Save the current file under a new name"),
725 )]);
726
727 let mut tree = tree_with_backend();
728 let btn = tree.add(Button::new(lit!("Save As")).rich_tooltip("save-as"));
729 tree.layout(SizeProposal::exact(400.0, 200.0));
730
731 assert!(tree.active_overlays().is_empty());
733
734 tree.pointer_move(tree.bounds(btn).center());
735 assert!(
736 tree.active_overlays().is_empty(),
737 "tooltip should not appear instantly — waits for delay"
738 );
739
740 tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
741
742 assert_eq!(
743 tree.active_overlays().len(),
744 1,
745 "rich tooltip should have appeared after the hover delay"
746 );
747
748 _reset_tooltip_registry();
749 }
750
751 #[test]
752 fn button_rich_tooltip_overrides_plain_tooltip() {
753 _reset_tooltip_registry();
754 install_tooltip_registry(vec![TooltipContent::new("help", lit!("Help body"))]);
755
756 let mut tree = tree_with_backend();
757 let btn = tree.add(
760 Button::new(lit!("Help"))
761 .tooltip(lit!("stale plain text"))
762 .rich_tooltip("help"),
763 );
764 tree.layout(SizeProposal::exact(400.0, 200.0));
765 tree.pointer_move(tree.bounds(btn).center());
766 tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
767
768 assert_eq!(tree.active_overlays().len(), 1);
769 assert!(
772 tree.find_by_label("stale plain text").is_none(),
773 "plain tooltip text should have been cleared by .rich_tooltip(...)"
774 );
775
776 _reset_tooltip_registry();
777 }
778
779 #[test]
780 fn rich_tooltip_shows_on_keyboard_focus_once_focus_rests() {
781 _reset_tooltip_registry();
782 install_tooltip_registry(vec![TooltipContent::new(
783 "focus-key",
784 lit!("Focus-shown body"),
785 )]);
786
787 let mut tree = tree_with_backend();
788 let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("focus-key"));
789 tree.layout(SizeProposal::exact(400.0, 200.0));
790
791 assert!(tree.active_overlays().is_empty());
792
793 tree.focus(btn);
796 assert!(
797 tree.active_overlays().is_empty(),
798 "focus arriving arms the delay; it does not show on arrival"
799 );
800 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
801
802 assert_eq!(
803 tree.active_overlays().len(),
804 1,
805 "rich tooltip appears once keyboard focus has rested for the delay"
806 );
807
808 _reset_tooltip_registry();
809 }
810
811 #[test]
812 fn focus_promoted_tooltip_dismisses_when_focus_leaves_scope() {
813 _reset_tooltip_registry();
814 install_tooltip_registry(vec![TooltipContent::new("leave-key", lit!("Goes away"))]);
815
816 let mut tree = tree_with_backend();
817 let btn = tree.add(Button::new(lit!("Anchor")).rich_tooltip("leave-key"));
818 let other = tree.add(Button::new(lit!("Elsewhere")));
819 tree.layout(SizeProposal::exact(400.0, 200.0));
820
821 tree.focus(btn);
822 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
823 assert_eq!(tree.active_overlays().len(), 1);
824
825 tree.focus(other);
829 assert!(
830 tree.active_overlays().is_empty(),
831 "focus-promoted sticky tooltip should dismiss when focus moves outside its scope"
832 );
833
834 _reset_tooltip_registry();
835 }
836
837 #[test]
838 fn button_plain_tooltip_appears_after_hover_delay() {
839 let mut tree = tree_with_backend();
840 let btn = tree.add(Button::new(lit!("Save")).tooltip(lit!("Save the document")));
841 tree.layout(SizeProposal::exact(400.0, 200.0));
842
843 assert!(tree.active_overlays().is_empty());
844 tree.pointer_move(tree.bounds(btn).center());
845 assert!(
846 tree.active_overlays().is_empty(),
847 "plain tooltip should not appear instantly — waits for delay"
848 );
849 tree.advance_time(Duration::from_millis(550));
851 assert_eq!(
852 tree.active_overlays().len(),
853 1,
854 "plain tooltip should have appeared after the hover delay"
855 );
856 }
857
858 #[test]
859 fn inline_content_tooltip_attaches_without_registry_key() {
860 _reset_tooltip_registry();
861 let mut tree = tree_with_backend();
863 let content = TooltipContent::new("inline-only", lit!("Inline content"));
864 let btn = tree.add(Button::new(lit!("Go")).rich_tooltip_content(content));
865 tree.layout(SizeProposal::exact(400.0, 200.0));
866 tree.pointer_move(tree.bounds(btn).center());
867 tree.advance_time(Duration::from_millis(500) + Duration::from_millis(50));
868
869 assert_eq!(tree.active_overlays().len(), 1);
870
871 _reset_tooltip_registry();
872 }
873
874 #[test]
877 fn menu_container_focus_does_not_fan_out_item_tooltips() {
878 _reset_tooltip_registry();
883 install_tooltip_registry(vec![
884 TooltipContent::new("a", lit!("Tip A")),
885 TooltipContent::new("b", lit!("Tip B")),
886 TooltipContent::new("c", lit!("Tip C")),
887 ]);
888
889 let mut tree = tree_with_backend();
890 let menu = tree.add(
891 MenuList::new()
892 .item(MenuItem::new(lit!("A")).rich_tooltip("a"))
893 .item(MenuItem::new(lit!("B")).rich_tooltip("b"))
894 .item(MenuItem::new(lit!("C")).rich_tooltip("c")),
895 );
896 tree.layout(SizeProposal::exact(400.0, 300.0));
897
898 tree.focus(menu);
899 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
900 assert!(
901 tree.active_overlays().is_empty(),
902 "focusing the menu container must not fan out item tooltips (the wall)"
903 );
904
905 _reset_tooltip_registry();
906 }
907
908 #[test]
909 fn self_anchored_focusable_tooltip_shows_exactly_one_overlay() {
910 let mut tree = tree_with_backend();
917 let anchor = tree.add(Button::new(lit!("Self")));
918 let content = tree.add(TooltipWidget::new(lit!("Tip")));
919 tree.attach_tooltip_with_sticky(
920 anchor,
921 content,
922 Duration::from_millis(200),
923 Some(Duration::from_secs(2)),
924 );
925 tree.layout(SizeProposal::exact(400.0, 200.0));
926
927 tree.focus(anchor);
928 tree.advance_time(Duration::from_millis(250));
929 assert_eq!(
930 tree.active_overlays().len(),
931 1,
932 "self-anchored focus shows exactly one overlay (no reflexive dup)"
933 );
934 }
935
936 #[test]
937 fn single_button_rich_tooltip_still_shows_on_focus() {
938 _reset_tooltip_registry();
942 install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
943 let mut tree = tree_with_backend();
944 let btn = tree.add(Button::new(lit!("Focus me")).rich_tooltip("k"));
945 tree.layout(SizeProposal::exact(400.0, 200.0));
946
947 tree.focus(btn);
948 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
949 assert_eq!(
950 tree.active_overlays().len(),
951 1,
952 "a single composing control still auto-shows its rich tooltip on focus"
953 );
954 _reset_tooltip_registry();
955 }
956
957 #[test]
958 fn segmented_control_focus_does_not_fan_out_segment_tooltips() {
959 _reset_tooltip_registry();
964 install_tooltip_registry(vec![
965 TooltipContent::new("s0", lit!("Seg 0")),
966 TooltipContent::new("s1", lit!("Seg 1")),
967 ]);
968 let mut tree = tree_with_backend();
969 let selected = teksilo_core::signal::Signal::new(None);
970 let sc = tree.add(
971 crate::segmented_control::SegmentedControl::new(selected)
972 .segment(crate::segmented_control::Segment::new(lit!("A")).rich_tooltip("s0"))
973 .segment(crate::segmented_control::Segment::new(lit!("B")).rich_tooltip("s1")),
974 );
975 tree.layout(SizeProposal::exact(400.0, 200.0));
976
977 tree.focus(sc);
978 assert!(
979 tree.active_overlays().is_empty(),
980 "focusing a SegmentedControl must not fan out its segment tooltips"
981 );
982 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
985 assert!(
986 tree.active_overlays().is_empty(),
987 "…and still none once the delay has elapsed"
988 );
989 _reset_tooltip_registry();
990 }
991
992 #[test]
995 fn side_placement_opens_to_the_trailing_side() {
996 let mut tree = tree_with_backend();
997 let anchor = tree.add(Button::new(lit!("Anchor")));
1000 let content = tree.add(TooltipWidget::new(lit!("Tip")));
1001 tree.attach_tooltip_with_placement(
1002 anchor,
1003 content,
1004 Duration::from_millis(200),
1005 TooltipPlacement::Side,
1006 );
1007 let _root = tree.add(VStack::new().add_child(anchor));
1008 tree.layout(SizeProposal::exact(600.0, 400.0));
1009 tree.pointer_move(tree.bounds(anchor).center());
1010 tree.advance_time(Duration::from_millis(250));
1011 tree.layout(SizeProposal::exact(600.0, 400.0));
1013
1014 let a = tree.bounds(anchor);
1015 let t = tree
1016 .overlay_manager()
1017 .bounds_for_content(content)
1018 .expect("Side tooltip overlay shown");
1019 assert!(
1020 t.x >= a.x + a.width,
1021 "Side tooltip opens to the trailing side: t.x {} >= anchor right {}",
1022 t.x,
1023 a.x + a.width
1024 );
1025 assert!(
1026 t.y < a.y + a.height,
1027 "Side tooltip is aligned to the anchor top, not below it"
1028 );
1029 }
1030
1031 #[test]
1032 fn below_placement_opens_under_the_anchor() {
1033 let mut tree = tree_with_backend();
1034 let anchor = tree.add(Button::new(lit!("Anchor")));
1035 let content = tree.add(TooltipWidget::new(lit!("Tip")));
1036 tree.attach_tooltip(anchor, content, Duration::from_millis(200));
1038 let _root = tree.add(VStack::new().add_child(anchor));
1039 tree.layout(SizeProposal::exact(600.0, 400.0));
1040 tree.pointer_move(tree.bounds(anchor).center());
1041 tree.advance_time(Duration::from_millis(250));
1042 tree.layout(SizeProposal::exact(600.0, 400.0));
1043
1044 let a = tree.bounds(anchor);
1045 let t = tree
1046 .overlay_manager()
1047 .bounds_for_content(content)
1048 .expect("Below tooltip overlay shown");
1049 assert!(
1050 t.y >= a.y + a.height,
1051 "Below tooltip opens under the anchor: t.y {} >= anchor bottom {}",
1052 t.y,
1053 a.y + a.height
1054 );
1055 }
1056
1057 #[test]
1060 fn keyboard_menu_navigation_surfaces_highlighted_item_tooltip() {
1061 _reset_tooltip_registry();
1062 install_tooltip_registry(vec![
1063 TooltipContent::new("a", lit!("Tip A")),
1064 TooltipContent::new("b", lit!("Tip B")),
1065 ]);
1066
1067 let mut tree = tree_with_backend();
1068 let menu = tree.add(
1071 MenuList::new()
1072 .item(MenuItem::new(lit!("A")).rich_tooltip("a"))
1073 .item(MenuItem::new(lit!("B")).rich_tooltip("b"))
1074 .item(MenuItem::new(lit!("C"))),
1075 );
1076 tree.layout(SizeProposal::exact(400.0, 300.0));
1077
1078 tree.focus(menu);
1080 assert!(
1081 tree.active_overlays().is_empty(),
1082 "no tooltip on menu focus (Part A)"
1083 );
1084
1085 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1087 assert_eq!(
1088 tree.active_overlays().len(),
1089 1,
1090 "ArrowDown surfaces the highlighted item's tooltip (Part C)"
1091 );
1092
1093 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1096 assert_eq!(
1097 tree.active_overlays().len(),
1098 1,
1099 "moving the highlight replaces the tooltip (still exactly one)"
1100 );
1101
1102 tree.press_key(Key::ArrowDown, Modifiers::NONE);
1105 assert!(
1106 tree.active_overlays().is_empty(),
1107 "highlighting a tooltip-less item clears the previous tooltip"
1108 );
1109
1110 _reset_tooltip_registry();
1111 }
1112
1113 #[test]
1116 fn dwelling_tooltip_wake_deadline_is_due_at_its_wake() {
1117 _reset_tooltip_registry();
1132 install_tooltip_registry(vec![TooltipContent::new("k", lit!("Body"))]);
1133 let mut tree = tree_with_backend();
1134 tree.set_accessibility_preferences(false, true, 1.0);
1138 let btn = tree.add(Button::new(lit!("Hover")).rich_tooltip("k"));
1139 tree.layout(SizeProposal::exact(400.0, 200.0));
1140 tree.pointer_move(tree.bounds(btn).center());
1141 tree.advance_time(Duration::from_millis(550)); tree.layout(SizeProposal::exact(400.0, 200.0));
1144 assert_eq!(tree.active_overlays().len(), 1, "rich tooltip shown");
1145
1146 std::thread::sleep(Duration::from_millis(600));
1150
1151 let deadline = tree
1152 .next_timer_deadline()
1153 .expect("a dwelling tooltip must schedule a wake deadline");
1154 assert!(
1155 deadline <= std::time::Instant::now(),
1156 "the dwell wake deadline must be DUE at its own wake (pinned to \
1157 last_frame_time); a still-future deadline is the freeze bug"
1158 );
1159
1160 _reset_tooltip_registry();
1161 }
1162
1163 #[test]
1164 fn plain_tooltip_schedules_no_dwell_wake() {
1165 let mut tree = tree_with_backend();
1171 tree.set_accessibility_preferences(false, true, 1.0); let btn = tree.add(Button::new(lit!("Hover")).tooltip(lit!("Plain")));
1173 tree.layout(SizeProposal::exact(400.0, 200.0));
1174 tree.pointer_move(tree.bounds(btn).center());
1175 tree.advance_time(Duration::from_millis(550));
1176 assert_eq!(tree.active_overlays().len(), 1, "plain tooltip shown");
1177 tree.layout(SizeProposal::exact(400.0, 200.0));
1178
1179 assert!(
1180 tree.next_timer_deadline().is_none(),
1181 "a plain tooltip must not schedule a dwell wake deadline"
1182 );
1183 }
1184}
1185
1186#[cfg(test)]
1200mod deferred_tooltip_drift {
1201 use std::path::{Path, PathBuf};
1202
1203 fn production_sources() -> Vec<(PathBuf, String)> {
1210 fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
1211 let Ok(entries) = std::fs::read_dir(dir) else {
1212 return;
1213 };
1214 for entry in entries.flatten() {
1215 let path = entry.path();
1216 if path.is_dir() {
1217 walk(&path, out);
1218 } else if path.extension().is_some_and(|e| e == "rs") {
1219 out.push(path);
1220 }
1221 }
1222 }
1223 let crates_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
1224 .parent()
1225 .expect("teksilo-widgets sits in crates/")
1226 .to_path_buf();
1227 let mut files = Vec::new();
1228 walk(&crates_dir, &mut files);
1229 files
1230 .into_iter()
1231 .filter_map(|path| {
1232 let text = std::fs::read_to_string(&path).ok()?;
1233 let production = match text.find("#[cfg(test)]") {
1234 Some(cut) => text[..cut].to_string(),
1235 None => text,
1236 };
1237 Some((path, production))
1238 })
1239 .collect()
1240 }
1241
1242 #[test]
1243 fn no_tooltip_body_is_added_eagerly() {
1244 const EAGER: [&str; 3] = ["ctx.add(", "ctx.add_boxed(", "ctx.add_detached("];
1248 const BODIES: [&str; 3] = [
1249 "TooltipWidget::new",
1250 "RichTooltipWidget::",
1251 "CompositeTooltipWidget::new",
1252 ];
1253
1254 let mut offenders: Vec<String> = Vec::new();
1255 for (path, text) in production_sources() {
1256 for (n, line) in text.lines().enumerate() {
1257 if line.trim_start().starts_with("//") {
1260 continue;
1261 }
1262 if EAGER.iter().any(|a| line.contains(a)) && BODIES.iter().any(|b| line.contains(b))
1267 {
1268 offenders.push(format!("{}:{}: {}", path.display(), n + 1, line.trim()));
1269 }
1270 }
1271 }
1272
1273 assert!(
1274 offenders.is_empty(),
1275 "a tooltip body is added eagerly — route it through \
1276 `attach_plain_tooltip`, `attach_rich_tooltip*` or \
1277 `attach_composite_tooltip*`, which defer it until a dwell matures:\n{}",
1278 offenders.join("\n")
1279 );
1280 }
1281}