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