1use std::cell::Cell;
27use std::rc::Rc;
28use std::time::Instant;
29
30use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
31use teksilo_core::accessibility::AccessNodeBuilder;
32use teksilo_core::build_context::BuildContext;
33use teksilo_core::signal::Signal;
34use teksilo_core::widget::{LayoutContext, PaintContext, Widget};
35use teksilo_core::widget_builder::HandlerSet;
36use teksilo_core::widget_id::WidgetId;
37use teksilo_i18n::LocalizedString;
38use teksilo_tokens::{CornerRadius, TextRole};
39
40use crate::primitives::{Grid, Padding, Spacer, TrackSize};
41use crate::scroll_area::{ScrollArea, ScrollBarPolicy};
42use crate::tooltip::dwell_indicator::DwellIndicator;
43use crate::tooltip::rich::{DWELL_STEP_DURATION, DWELL_STEPS};
46
47pub struct CompositeTooltipWidget {
50 body: Option<Box<dyn Widget>>,
51 body_id: Option<WidgetId>,
52 padded_id: Option<WidgetId>,
55 footer_id: Option<WidgetId>,
56 scrolled_id: Option<WidgetId>,
59 access_label: Option<String>,
60 max_width_override: Option<f32>,
61 max_height_override: Option<f32>,
62 dwell_step: Signal<u32>,
63 sticky: Signal<bool>,
64 sticky_enabled: bool,
73 shown_at_sink: Rc<Cell<Option<Instant>>>,
74}
75
76impl Default for CompositeTooltipWidget {
77 fn default() -> Self {
78 Self::new()
79 }
80}
81
82impl std::fmt::Debug for CompositeTooltipWidget {
83 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84 f.debug_struct("CompositeTooltipWidget")
85 .field("has_body", &self.body.is_some())
86 .field("access_label", &self.access_label)
87 .field("max_width_override", &self.max_width_override)
88 .field("max_height_override", &self.max_height_override)
89 .field("sticky_enabled", &self.sticky_enabled)
90 .finish()
91 }
92}
93
94impl CompositeTooltipWidget {
95 pub fn new() -> Self {
96 Self {
97 body: None,
98 body_id: None,
99 padded_id: None,
100 footer_id: None,
101 scrolled_id: None,
102 access_label: None,
103 max_width_override: None,
104 max_height_override: None,
105 dwell_step: Signal::new(0),
106 sticky: Signal::new(false),
107 sticky_enabled: true,
110 shown_at_sink: Rc::new(Cell::new(None)),
111 }
112 }
113
114 pub fn content(mut self, body: impl Widget + 'static) -> Self {
116 self.body = Some(Box::new(body));
117 self
118 }
119
120 pub fn content_boxed(mut self, body: Box<dyn Widget>) -> Self {
124 self.body = Some(body);
125 self
126 }
127
128 pub fn access_label(mut self, label: impl Into<LocalizedString>) -> Self {
131 let ls: LocalizedString = label.into();
132 self.access_label = Some(ls.resolve_now());
133 self
134 }
135
136 pub fn sticky(mut self, on: bool) -> Self {
157 self.sticky_enabled = on;
158 self
159 }
160
161 pub fn sticky_enabled(&self) -> bool {
164 self.sticky_enabled
165 }
166
167 pub fn max_width(mut self, w: f32) -> Self {
168 self.max_width_override = Some(w);
169 self
170 }
171
172 pub fn max_height(mut self, h: f32) -> Self {
174 self.max_height_override = Some(h);
175 self
176 }
177
178 pub fn shown_at_sink(&self) -> Rc<Cell<Option<Instant>>> {
181 self.shown_at_sink.clone()
182 }
183
184 fn tick_dwell(&self) {
185 let Some(shown_at) = self.shown_at_sink.get() else {
186 if self.dwell_step.get() != 0 {
187 self.dwell_step.set(0);
188 }
189 if self.sticky.get() {
190 self.sticky.set(false);
191 }
192 return;
193 };
194 let elapsed = Instant::now().saturating_duration_since(shown_at);
195 let new_step =
196 ((elapsed.as_millis() / DWELL_STEP_DURATION.as_millis()) as u32).min(DWELL_STEPS);
197 if self.dwell_step.get() != new_step {
198 self.dwell_step.set(new_step);
199 }
200 let now_sticky = new_step >= DWELL_STEPS;
201 if self.sticky.get() != now_sticky {
202 self.sticky.set(now_sticky);
203 }
204 }
205}
206
207const FOOTER_GAP: f32 = 6.0;
210
211impl Widget for CompositeTooltipWidget {
212 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
213 use crate::styles::recipe_tooltip_style as tt;
214 let self_id = ctx.self_id();
215
216 let body_id = if let Some(body) = self.body.take() {
229 let id = ctx.add_boxed(body);
230 self.body_id = Some(id);
231 id
232 } else if let Some(id) = self.body_id {
233 id
234 } else {
235 let id = ctx.add(Spacer::new());
236 self.body_id = Some(id);
237 id
238 };
239
240 let scrolled = ctx.add(
244 ScrollArea::from_id(body_id)
245 .vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
246 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
247 .scroll_bar_thumb_color(TextRole::TooltipText),
251 );
252
253 self.scrolled_id = Some(scrolled);
254
255 let padded = ctx.add(
256 Padding::symmetric(
257 tt::COMPOSITE_TOOLTIP_PADDING_VERTICAL,
258 tt::COMPOSITE_TOOLTIP_PADDING_HORIZONTAL,
259 )
260 .child_id(scrolled),
261 );
262
263 let footer = self.sticky_enabled.then(|| {
267 let indicator = ctx.add(DwellIndicator::new(
268 self.dwell_step.clone(),
269 self.sticky.clone(),
270 TextRole::TooltipText,
271 ));
272 let footer_spacer = ctx.add(Spacer::new());
277 ctx.add(
278 Grid::new()
279 .columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
280 .rows(vec![TrackSize::Auto])
281 .column_gap(8.0)
282 .add_child(footer_spacer)
283 .add_child(indicator),
284 )
285 });
286
287 self.padded_id = Some(padded);
298 self.footer_id = footer;
299
300 let handlers = HandlerSet::new().focusable(true);
302 ctx.apply_self_handlers(handlers);
303
304 self.sticky.bind_to(
306 self_id,
307 ctx.binding_registry(),
308 teksilo_core::binding::BindingLevel::AccessibilityOnly,
309 );
310
311 self.padded_id.into_iter().chain(self.footer_id).collect()
312 }
313
314 fn layout_response(
315 &self,
316 proposal: SizeProposal,
317 ctx: &LayoutContext,
318 ) -> teksilo_core::widget::LayoutResponse {
319 use crate::styles::recipe_tooltip_style as tt;
320 let max_w = self
321 .max_width_override
322 .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_WIDTH);
323 let max_h = self
324 .max_height_override
325 .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_HEIGHT);
326 let unbounded = SizeProposal {
339 width: None,
340 height: None,
341 };
342 let Some(padded) = self.padded_id else {
343 return Size::new(0.0, 0.0).into();
344 };
345 let footer_natural = self
346 .footer_id
347 .and_then(|id| ctx.child_size(id, unbounded))
348 .unwrap_or_else(|| Size::new(0.0, 0.0));
349 let Some(padded_natural) = ctx.child_size(padded, unbounded) else {
350 return Size::new(0.0, 0.0).into();
351 };
352 let natural = Size::new(
353 padded_natural.width.max(footer_natural.width),
354 padded_natural.height + FOOTER_GAP + footer_natural.height,
355 );
356 let avail_w = proposal.width.unwrap_or(f32::INFINITY).min(max_w);
357 let w = natural.width.min(avail_w);
358 let at_w = SizeProposal {
359 width: Some(w),
360 height: None,
361 };
362 let footer_h = self
363 .footer_id
364 .and_then(|id| ctx.child_size(id, at_w))
365 .map(|s| s.height)
366 .unwrap_or(footer_natural.height);
367 let h = ctx
368 .child_size(padded, at_w)
369 .map(|s| s.height + FOOTER_GAP + footer_h)
370 .unwrap_or(natural.height);
371
372 let h = match (self.scrolled_id, self.body_id) {
381 (Some(scrolled), Some(body)) => {
382 let at_width = SizeProposal {
383 width: Some(w),
384 height: None,
385 };
386 match (
387 ctx.child_size(scrolled, at_width),
388 ctx.child_size(body, at_width),
389 ) {
390 (Some(vp), Some(content)) => (h - vp.height + content.height).max(0.0),
391 _ => h,
392 }
393 }
394 _ => h,
395 };
396 let avail_h = proposal.height.unwrap_or(f32::INFINITY).min(max_h);
397 Size::new(w, h.min(avail_h)).into()
398 }
399
400 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
401 let radius = CornerRadius::uniform(
402 crate::styles::recipe_tooltip_style::COMPOSITE_TOOLTIP_CORNER_RADIUS,
403 );
404 let _ = ctx;
405 super::paint_composite_tooltip_shadows(canvas, bounds, radius, ctx);
406 canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
407 if self.sticky_enabled {
410 self.tick_dwell();
411 }
412 }
413
414 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
415 let is_sticky = self.sticky.get();
416 let role = if is_sticky {
417 teksilo_core::accesskit::Role::Dialog
418 } else {
419 teksilo_core::accesskit::Role::Tooltip
420 };
421 builder.set_role(role);
422 let name = self
427 .access_label
428 .clone()
429 .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_tooltip_name()).resolve_now());
430 builder.set_name(name);
431 if is_sticky {
432 builder.add_action(teksilo_core::accesskit::Action::Focus);
433 }
434 }
435
436 fn children(&self) -> Vec<WidgetId> {
437 self.padded_id.into_iter().chain(self.footer_id).collect()
438 }
439
440 fn place_children(
448 &self,
449 bounds: Rect,
450 _proposal: SizeProposal,
451 children: &mut [teksilo_core::widget::WidgetPlacement],
452 ctx: &LayoutContext,
453 ) {
454 let at_w = SizeProposal {
455 width: Some(bounds.width),
456 height: None,
457 };
458 let footer_h = self
459 .footer_id
460 .and_then(|id| ctx.child_size(id, at_w))
461 .map(|s| s.height)
462 .unwrap_or(0.0);
463 let body_h = (bounds.height - footer_h - FOOTER_GAP).max(0.0);
464 for (i, child) in children.iter_mut().enumerate() {
465 if i == 0 {
466 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
467 child.size = Size::new(bounds.width, body_h);
468 } else {
469 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y + body_h + FOOTER_GAP);
470 child.size = Size::new(bounds.width, footer_h);
471 }
472 }
473 }
474
475 fn preserves_children_on_rebuild(&self) -> bool {
485 true
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::button::Button;
493 use crate::primitives::{TextWidget, VStack};
494 use std::cell::RefCell;
495 use std::rc::Rc;
496 use std::time::Duration;
497 use teksilo_canvas::{MockTextBackend, SizeProposal};
498 use teksilo_core::widget_tree::WidgetTree;
499 use teksilo_i18n::lit;
500
501 fn tree_with_backend() -> WidgetTree {
502 WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
503 }
504
505 #[derive(Debug)]
510 struct ComposeTooltipHost {
511 anchor_id: Option<WidgetId>,
512 tooltip_id_sink: Rc<Cell<Option<WidgetId>>>,
513 sticky: bool,
514 }
515
516 impl ComposeTooltipHost {
517 fn new(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
518 Self {
519 anchor_id: None,
520 tooltip_id_sink,
521 sticky: true,
522 }
523 }
524 fn new_non_sticky(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
525 Self {
526 anchor_id: None,
527 tooltip_id_sink,
528 sticky: false,
529 }
530 }
531 }
532
533 impl Widget for ComposeTooltipHost {
534 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
535 let anchor = ctx.add(Button::new(lit!("Hover me")));
536 self.anchor_id = Some(anchor);
537 let body = VStack::new()
538 .child(TextWidget::new(lit!("Header")))
539 .child(TextWidget::new(lit!("Body")));
540 let delay = ctx.theme().motion.tooltip_delay_heavy;
541 let tip = crate::tooltip::attach_composite_tooltip_widget_with_placement(
542 ctx,
543 anchor,
544 CompositeTooltipWidget::new()
545 .content(body)
546 .sticky(self.sticky),
547 delay,
548 crate::tooltip::TooltipPlacement::Below,
549 );
550 self.tooltip_id_sink.set(Some(tip));
551 vec![anchor]
552 }
553 fn layout_response(
554 &self,
555 proposal: SizeProposal,
556 ctx: &LayoutContext,
557 ) -> teksilo_core::widget::LayoutResponse {
558 self.anchor_id
559 .and_then(|id| ctx.child_size(id, proposal))
560 .unwrap_or_else(|| Size::new(0.0, 0.0))
561 .into()
562 }
563 fn children(&self) -> Vec<WidgetId> {
564 self.anchor_id.map(|id| vec![id]).unwrap_or_default()
565 }
566 }
567
568 #[test]
569 fn composite_tooltip_appears_after_hover_delay() {
570 let mut tree = tree_with_backend();
571 let tooltip_id_sink = Rc::new(Cell::new(None));
572 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
573 tree.layout(SizeProposal::exact(400.0, 200.0));
574
575 assert!(tree.active_overlays().is_empty());
576 tree.pointer_move(tree.bounds(host).center());
577 assert!(
578 tree.active_overlays().is_empty(),
579 "composite tooltip should not appear instantly — waits for delay"
580 );
581
582 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
583 assert_eq!(
584 tree.active_overlays().len(),
585 1,
586 "composite tooltip should have appeared after the hover delay"
587 );
588 }
589
590 #[test]
591 fn composite_tooltip_does_not_appear_at_the_light_tier_delay() {
592 let mut tree = tree_with_backend();
598 let tooltip_id_sink = Rc::new(Cell::new(None));
599 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
600 tree.layout(SizeProposal::exact(400.0, 200.0));
601
602 tree.pointer_move(tree.bounds(host).center());
603 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
604 assert!(
605 tree.active_overlays().is_empty(),
606 "a composite tooltip must still be waiting at the plain 500 ms delay"
607 );
608
609 tree.advance_time(Duration::from_millis(200));
610 assert_eq!(
611 tree.active_overlays().len(),
612 1,
613 "and appear once the 700 ms heavy delay elapses"
614 );
615 }
616
617 #[test]
618 fn composite_tooltip_dismisses_on_pointer_leave_before_promotion() {
619 let mut tree = tree_with_backend();
620 let tooltip_id_sink = Rc::new(Cell::new(None));
621 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
622 tree.layout(SizeProposal::exact(400.0, 200.0));
623
624 tree.pointer_move(tree.bounds(host).center());
625 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
626 assert_eq!(tree.active_overlays().len(), 1);
627
628 tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
630 tree.advance_time(Duration::from_millis(500));
631 assert!(
632 tree.active_overlays().is_empty(),
633 "non-sticky composite tooltip should dismiss on pointer-leave"
634 );
635 }
636
637 #[test]
638 fn composite_tooltip_survives_pointer_leave_once_promoted() {
639 let mut tree = tree_with_backend();
643 let tooltip_id_sink = Rc::new(Cell::new(None));
644 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
645 tree.layout(SizeProposal::exact(400.0, 200.0));
646
647 tree.pointer_move(tree.bounds(host).center());
648 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
649 assert_eq!(tree.active_overlays().len(), 1);
650
651 let content_id = tooltip_id_sink
652 .get()
653 .expect("tooltip id captured during build");
654 tree.promote_tooltip_to_sticky(content_id);
655
656 tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
657 tree.advance_time(Duration::from_millis(500));
658 assert_eq!(
659 tree.active_overlays().len(),
660 1,
661 "sticky composite tooltip should survive pointer-leave"
662 );
663 }
664
665 #[test]
666 fn composite_tooltip_preserves_children_so_body_survives_rebuild() {
667 let w = CompositeTooltipWidget::new().content(TextWidget::new(lit!("Body")));
673 assert!(
674 w.preserves_children_on_rebuild(),
675 "composite must preserve children so the reused body id stays valid across rebuild"
676 );
677 }
678
679 #[test]
685 fn a_non_sticky_composite_tooltip_never_promotes() {
686 let mut tree = tree_with_backend();
687 let tooltip_id_sink = Rc::new(Cell::new(None));
688 let host = tree.add(ComposeTooltipHost::new_non_sticky(tooltip_id_sink.clone()));
689 tree.layout(SizeProposal::exact(400.0, 200.0));
690
691 tree.pointer_move(tree.bounds(host).center());
692 tree.advance_time(Duration::from_millis(750));
693 assert_eq!(
694 tree.active_overlays().len(),
695 1,
696 "it still shows on hover — only the promotion is gone"
697 );
698
699 tree.advance_time(crate::tooltip::rich::DWELL_PROMOTION * 3);
701 tree.pointer_move(teksilo_canvas::Point::new(-100.0, -100.0));
702 tree.advance_time(Duration::from_millis(300));
703 assert!(
704 tree.active_overlays().is_empty(),
705 "a non-sticky surface must retire with the pointer, not survive it"
706 );
707 }
708
709 #[test]
711 fn a_non_sticky_composite_tooltip_is_shorter_than_a_sticky_one() {
712 let measure = |sticky: bool| {
713 let mut tree = WidgetTree::new()
714 .with_theme(teksilo_core::presets::intui::light())
715 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
716 tree.add_boxed(Box::new(
717 CompositeTooltipWidget::new()
718 .content(TextWidget::new(lit!("Hi")))
719 .sticky(sticky),
720 ));
721 tree.layout(SizeProposal::exact(1200.0, 900.0));
722 tree.measure_root_intrinsic(SizeProposal {
723 width: Some(1200.0),
724 height: Some(900.0),
725 })
726 .expect("a size")
727 .height
728 };
729 let sticky = measure(true);
730 let plain = measure(false);
731 assert!(
732 plain < sticky,
733 "without the indicator the surface should hug tighter \
734 (non-sticky {plain}, sticky {sticky})"
735 );
736 }
737
738 #[test]
746 fn the_dwell_indicator_sits_inside_the_tooltip_surface() {
747 let mut tree = WidgetTree::new()
748 .with_theme(teksilo_core::presets::intui::light())
749 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
750 let tip = tree.add_boxed(Box::new(
751 CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
752 ));
753 let want = tree
757 .measure_root_intrinsic(SizeProposal {
758 width: Some(1200.0),
759 height: Some(900.0),
760 })
761 .expect("the tooltip reports a size");
762 tree.layout(SizeProposal::exact(want.width, want.height));
763
764 let surface = tree.bounds(tip);
765 let mut stack = tree.children(tip);
767 let mut worst: Option<(f32, f32)> = None;
768 while let Some(id) = stack.pop() {
769 let b = tree.bounds(id);
770 if b.height > 0.0 && b.y + b.height > surface.y + surface.height + 0.5 {
771 let overflow = (b.y + b.height) - (surface.y + surface.height);
772 if worst.is_none_or(|(w, _)| overflow > w) {
773 worst = Some((overflow, b.y + b.height));
774 }
775 }
776 stack.extend(tree.children(id));
777 }
778 assert!(
779 worst.is_none(),
780 "a descendant spills {:.1}dp below the tooltip surface \
781 (surface ends at {:.1}, child at {:.1}) — the dwell indicator is \
782 painted outside the bubble",
783 worst.unwrap().0,
784 surface.y + surface.height,
785 worst.unwrap().1,
786 );
787 }
788
789 #[test]
797 fn a_short_composite_tooltip_hugs_its_content() {
798 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
799 tree.add_boxed(Box::new(
800 CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
801 ));
802 tree.layout(SizeProposal::exact(1200.0, 900.0));
806 let s = tree
807 .measure_root_intrinsic(SizeProposal {
808 width: Some(1200.0),
809 height: Some(900.0),
810 })
811 .expect("the tooltip reports a size");
812 assert!(
813 s.width < 200.0,
814 "a two-letter body should not ask for a {}dp-wide tooltip",
815 s.width
816 );
817 assert!(
818 s.height < 120.0,
819 "a one-line body should not ask for a {}dp-tall tooltip",
820 s.height
821 );
822 }
823
824 #[test]
826 fn a_long_composite_tooltip_is_capped_by_its_maximum() {
827 let long = "word ".repeat(400);
828 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
829 tree.add_boxed(Box::new(
830 CompositeTooltipWidget::new()
831 .content(TextWidget::new(lit!(long)))
832 .max_width(240.0)
833 .max_height(160.0),
834 ));
835 tree.layout(SizeProposal::exact(1200.0, 900.0));
836 let s = tree
837 .measure_root_intrinsic(SizeProposal {
838 width: Some(1200.0),
839 height: Some(900.0),
840 })
841 .expect("the tooltip reports a size");
842 assert!(s.width <= 240.5, "width {} exceeds its maximum", s.width);
843 assert!(s.height <= 160.5, "height {} exceeds its maximum", s.height);
844 }
845}