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, InputTokens, 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};
46use teksilo_core::styles::density::spacing;
47
48pub struct CompositeTooltipWidget {
51 body: Option<Box<dyn Widget>>,
52 body_id: Option<WidgetId>,
53 padded_id: Option<WidgetId>,
56 footer_id: Option<WidgetId>,
57 scrolled_id: Option<WidgetId>,
60 access_label: Option<String>,
61 max_width_override: Option<f32>,
62 max_height_override: Option<f32>,
63 dwell_step: Signal<u32>,
64 sticky: Signal<bool>,
65 sticky_enabled: bool,
74 shown_at_sink: Rc<Cell<Option<Instant>>>,
75}
76
77impl Default for CompositeTooltipWidget {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl std::fmt::Debug for CompositeTooltipWidget {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 f.debug_struct("CompositeTooltipWidget")
86 .field("has_body", &self.body.is_some())
87 .field("access_label", &self.access_label)
88 .field("max_width_override", &self.max_width_override)
89 .field("max_height_override", &self.max_height_override)
90 .field("sticky_enabled", &self.sticky_enabled)
91 .finish()
92 }
93}
94
95impl CompositeTooltipWidget {
96 pub fn new() -> Self {
97 Self {
98 body: None,
99 body_id: None,
100 padded_id: None,
101 footer_id: None,
102 scrolled_id: None,
103 access_label: None,
104 max_width_override: None,
105 max_height_override: None,
106 dwell_step: Signal::new(0),
107 sticky: Signal::new(false),
108 sticky_enabled: true,
111 shown_at_sink: Rc::new(Cell::new(None)),
112 }
113 }
114
115 pub fn content(mut self, body: impl Widget + 'static) -> Self {
117 self.body = Some(Box::new(body));
118 self
119 }
120
121 pub fn content_boxed(mut self, body: Box<dyn Widget>) -> Self {
125 self.body = Some(body);
126 self
127 }
128
129 pub fn access_label(mut self, label: impl Into<LocalizedString>) -> Self {
132 let ls: LocalizedString = label.into();
133 self.access_label = Some(ls.resolve_now());
134 self
135 }
136
137 pub fn sticky(mut self, on: bool) -> Self {
159 self.sticky_enabled = on;
160 self
161 }
162
163 pub fn sticky_enabled(&self) -> bool {
166 self.sticky_enabled
167 }
168
169 pub fn max_width(mut self, w: f32) -> Self {
170 self.max_width_override = Some(w);
171 self
172 }
173
174 pub fn max_height(mut self, h: f32) -> Self {
176 self.max_height_override = Some(h);
177 self
178 }
179
180 pub fn shown_at_sink(&self) -> Rc<Cell<Option<Instant>>> {
183 self.shown_at_sink.clone()
184 }
185
186 fn tick_dwell(&self) {
187 let Some(shown_at) = self.shown_at_sink.get() else {
188 if self.dwell_step.get() != 0 {
189 self.dwell_step.set(0);
190 }
191 if self.sticky.get() {
192 self.sticky.set(false);
193 }
194 return;
195 };
196 let elapsed = Instant::now().saturating_duration_since(shown_at);
197 let new_step =
198 ((elapsed.as_millis() / DWELL_STEP_DURATION.as_millis()) as u32).min(DWELL_STEPS);
199 if self.dwell_step.get() != new_step {
200 self.dwell_step.set(new_step);
201 }
202 let now_sticky = new_step >= DWELL_STEPS;
203 if self.sticky.get() != now_sticky {
204 self.sticky.set(now_sticky);
205 }
206 }
207}
208
209const FOOTER_GAP: f32 = 6.0;
212
213fn footer_gap(tokens: &InputTokens) -> f32 {
216 spacing(FOOTER_GAP, tokens)
217}
218
219impl Widget for CompositeTooltipWidget {
220 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
221 use crate::styles::recipe_tooltip_style as tt;
222 let self_id = ctx.self_id();
223
224 let body_id = if let Some(body) = self.body.take() {
237 let id = ctx.add_boxed(body);
238 self.body_id = Some(id);
239 id
240 } else if let Some(id) = self.body_id {
241 id
242 } else {
243 let id = ctx.add(Spacer::new());
244 self.body_id = Some(id);
245 id
246 };
247
248 let scrolled = ctx.add(
252 ScrollArea::from_id(body_id)
253 .vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
254 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
255 .scroll_bar_thumb_color(TextRole::TooltipText),
259 );
260
261 self.scrolled_id = Some(scrolled);
262
263 let padded = ctx.add(
264 Padding::symmetric(
265 tt::COMPOSITE_TOOLTIP_PADDING_VERTICAL,
266 tt::COMPOSITE_TOOLTIP_PADDING_HORIZONTAL,
267 )
268 .child(scrolled),
269 );
270
271 let footer = self.sticky_enabled.then(|| {
275 let indicator = ctx.add(DwellIndicator::new(
276 self.dwell_step.clone(),
277 self.sticky.clone(),
278 TextRole::TooltipText,
279 ));
280 let footer_spacer = ctx.add(Spacer::new());
285 ctx.add(
286 Grid::new()
287 .columns(vec![TrackSize::Fractional(1.0), TrackSize::Auto])
288 .rows(vec![TrackSize::Auto])
289 .column_gap(8.0)
290 .child(footer_spacer)
291 .child(indicator),
292 )
293 });
294
295 self.padded_id = Some(padded);
306 self.footer_id = footer;
307
308 let handlers = HandlerSet::new().focusable(true);
310 ctx.apply_self_handlers(handlers);
311
312 self.sticky.bind_to(
314 self_id,
315 ctx.binding_registry(),
316 teksilo_core::binding::BindingLevel::AccessibilityOnly,
317 );
318
319 self.padded_id.into_iter().chain(self.footer_id).collect()
320 }
321
322 fn layout_response(
323 &self,
324 proposal: SizeProposal,
325 ctx: &LayoutContext,
326 ) -> teksilo_core::widget::LayoutResponse {
327 use crate::styles::recipe_tooltip_style as tt;
328 let max_w = self
329 .max_width_override
330 .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_WIDTH);
331 let max_h = self
332 .max_height_override
333 .unwrap_or(tt::COMPOSITE_TOOLTIP_MAX_HEIGHT);
334 let unbounded = SizeProposal {
347 width: None,
348 height: None,
349 };
350 let Some(padded) = self.padded_id else {
351 return Size::new(0.0, 0.0).into();
352 };
353 let footer_natural = self
354 .footer_id
355 .and_then(|id| ctx.child_size(id, unbounded))
356 .unwrap_or_else(|| Size::new(0.0, 0.0));
357 let Some(padded_natural) = ctx.child_size(padded, unbounded) else {
358 return Size::new(0.0, 0.0).into();
359 };
360 let natural = Size::new(
361 padded_natural.width.max(footer_natural.width),
362 padded_natural.height + footer_gap(&ctx.theme.input) + footer_natural.height,
363 );
364 let avail_w = proposal.width.unwrap_or(f32::INFINITY).min(max_w);
365 let w = natural.width.min(avail_w);
366 let at_w = SizeProposal {
367 width: Some(w),
368 height: None,
369 };
370 let footer_h = self
371 .footer_id
372 .and_then(|id| ctx.child_size(id, at_w))
373 .map(|s| s.height)
374 .unwrap_or(footer_natural.height);
375 let h = ctx
376 .child_size(padded, at_w)
377 .map(|s| s.height + footer_gap(&ctx.theme.input) + footer_h)
378 .unwrap_or(natural.height);
379
380 let h = match (self.scrolled_id, self.body_id) {
389 (Some(scrolled), Some(body)) => {
390 let at_width = SizeProposal {
391 width: Some(w),
392 height: None,
393 };
394 match (
395 ctx.child_size(scrolled, at_width),
396 ctx.child_size(body, at_width),
397 ) {
398 (Some(vp), Some(content)) => (h - vp.height + content.height).max(0.0),
399 _ => h,
400 }
401 }
402 _ => h,
403 };
404 let avail_h = proposal.height.unwrap_or(f32::INFINITY).min(max_h);
405 Size::new(w, h.min(avail_h)).into()
406 }
407
408 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
409 let radius = CornerRadius::uniform(
410 crate::styles::recipe_tooltip_style::COMPOSITE_TOOLTIP_CORNER_RADIUS,
411 );
412 let _ = ctx;
413 super::paint_composite_tooltip_shadows(canvas, bounds, radius, ctx);
414 canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.tooltip_bg);
415 if self.sticky_enabled {
418 self.tick_dwell();
419 }
420 }
421
422 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
423 let is_sticky = self.sticky.get();
424 let role = if is_sticky {
425 teksilo_core::accesskit::Role::Dialog
426 } else {
427 teksilo_core::accesskit::Role::Tooltip
428 };
429 builder.set_role(role);
430 let name = self
435 .access_label
436 .clone()
437 .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_tooltip_name()).resolve_now());
438 builder.set_name(name);
439 if is_sticky {
440 builder.add_action(teksilo_core::accesskit::Action::Focus);
441 }
442 }
443
444 fn children(&self) -> Vec<WidgetId> {
445 self.padded_id.into_iter().chain(self.footer_id).collect()
446 }
447
448 fn place_children(
456 &self,
457 bounds: Rect,
458 _proposal: SizeProposal,
459 children: &mut [teksilo_core::widget::WidgetPlacement],
460 ctx: &LayoutContext,
461 ) {
462 let at_w = SizeProposal {
463 width: Some(bounds.width),
464 height: None,
465 };
466 let footer_h = self
467 .footer_id
468 .and_then(|id| ctx.child_size(id, at_w))
469 .map(|s| s.height)
470 .unwrap_or(0.0);
471 let gap = footer_gap(&ctx.theme.input);
472 let body_h = (bounds.height - footer_h - gap).max(0.0);
473 for (i, child) in children.iter_mut().enumerate() {
474 if i == 0 {
475 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y);
476 child.size = Size::new(bounds.width, body_h);
477 } else {
478 child.origin = teksilo_canvas::Point::new(bounds.x, bounds.y + body_h + gap);
479 child.size = Size::new(bounds.width, footer_h);
480 }
481 }
482 }
483
484 fn preserves_children_on_rebuild(&self) -> bool {
494 true
495 }
496}
497
498#[cfg(test)]
499mod tests {
500 use super::*;
501 use crate::button::Button;
502 use crate::primitives::{TextWidget, VStack};
503 use std::cell::RefCell;
504 use std::rc::Rc;
505 use std::time::Duration;
506 use teksilo_canvas::{MockTextBackend, SizeProposal};
507 use teksilo_core::widget_tree::WidgetTree;
508 use teksilo_i18n::lit;
509
510 fn tree_with_backend() -> WidgetTree {
511 WidgetTree::new().with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())))
512 }
513
514 #[derive(Debug)]
519 struct ComposeTooltipHost {
520 anchor_id: Option<WidgetId>,
521 tooltip_id_sink: Rc<Cell<Option<WidgetId>>>,
522 sticky: bool,
523 }
524
525 impl ComposeTooltipHost {
526 fn new(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
527 Self {
528 anchor_id: None,
529 tooltip_id_sink,
530 sticky: true,
531 }
532 }
533 fn new_non_sticky(tooltip_id_sink: Rc<Cell<Option<WidgetId>>>) -> Self {
534 Self {
535 anchor_id: None,
536 tooltip_id_sink,
537 sticky: false,
538 }
539 }
540 }
541
542 impl Widget for ComposeTooltipHost {
543 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
544 let anchor = ctx.add(Button::new(lit!("Hover me")));
545 self.anchor_id = Some(anchor);
546 let body = VStack::new()
547 .child(TextWidget::new(lit!("Header")))
548 .child(TextWidget::new(lit!("Body")));
549 let delay = ctx.theme().motion.tooltip_delay_heavy;
550 let tip = crate::tooltip::attach_composite_tooltip_widget_with_placement(
551 ctx,
552 anchor,
553 CompositeTooltipWidget::new()
554 .content(body)
555 .sticky(self.sticky),
556 delay,
557 crate::tooltip::TooltipPlacement::Below,
558 );
559 self.tooltip_id_sink.set(Some(tip));
560 vec![anchor]
561 }
562 fn layout_response(
563 &self,
564 proposal: SizeProposal,
565 ctx: &LayoutContext,
566 ) -> teksilo_core::widget::LayoutResponse {
567 self.anchor_id
568 .and_then(|id| ctx.child_size(id, proposal))
569 .unwrap_or_else(|| Size::new(0.0, 0.0))
570 .into()
571 }
572 fn children(&self) -> Vec<WidgetId> {
573 self.anchor_id.map(|id| vec![id]).unwrap_or_default()
574 }
575 }
576
577 #[test]
578 fn composite_tooltip_appears_after_hover_delay() {
579 let mut tree = tree_with_backend();
580 let tooltip_id_sink = Rc::new(Cell::new(None));
581 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
582 tree.layout(SizeProposal::exact(400.0, 200.0));
583
584 assert!(tree.active_overlays().is_empty());
585 tree.pointer_move(tree.bounds(host).center());
586 assert!(
587 tree.active_overlays().is_empty(),
588 "composite tooltip should not appear instantly — waits for delay"
589 );
590
591 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
592 assert_eq!(
593 tree.active_overlays().len(),
594 1,
595 "composite tooltip should have appeared after the hover delay"
596 );
597 }
598
599 #[test]
600 fn composite_tooltip_does_not_appear_at_the_light_tier_delay() {
601 let mut tree = tree_with_backend();
607 let tooltip_id_sink = Rc::new(Cell::new(None));
608 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
609 tree.layout(SizeProposal::exact(400.0, 200.0));
610
611 tree.pointer_move(tree.bounds(host).center());
612 tree.advance_time(tree.theme().motion.tooltip_delay + Duration::from_millis(50));
613 assert!(
614 tree.active_overlays().is_empty(),
615 "a composite tooltip must still be waiting at the plain 500 ms delay"
616 );
617
618 tree.advance_time(Duration::from_millis(200));
619 assert_eq!(
620 tree.active_overlays().len(),
621 1,
622 "and appear once the 700 ms heavy delay elapses"
623 );
624 }
625
626 #[test]
627 fn composite_tooltip_dismisses_on_pointer_leave_before_promotion() {
628 let mut tree = tree_with_backend();
629 let tooltip_id_sink = Rc::new(Cell::new(None));
630 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
631 tree.layout(SizeProposal::exact(400.0, 200.0));
632
633 tree.pointer_move(tree.bounds(host).center());
634 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
635 assert_eq!(tree.active_overlays().len(), 1);
636
637 tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
639 tree.advance_time(Duration::from_millis(500));
640 assert!(
641 tree.active_overlays().is_empty(),
642 "non-sticky composite tooltip should dismiss on pointer-leave"
643 );
644 }
645
646 #[test]
647 fn composite_tooltip_survives_pointer_leave_once_promoted() {
648 let mut tree = tree_with_backend();
652 let tooltip_id_sink = Rc::new(Cell::new(None));
653 let host = tree.add(ComposeTooltipHost::new(tooltip_id_sink.clone()));
654 tree.layout(SizeProposal::exact(400.0, 200.0));
655
656 tree.pointer_move(tree.bounds(host).center());
657 tree.advance_time(Duration::from_millis(700) + Duration::from_millis(50));
658 assert_eq!(tree.active_overlays().len(), 1);
659
660 let content_id = tooltip_id_sink
661 .get()
662 .expect("tooltip id captured during build");
663 tree.promote_tooltip_to_sticky(content_id);
664
665 tree.pointer_move(teksilo_canvas::Point::new(2000.0, 2000.0));
666 tree.advance_time(Duration::from_millis(500));
667 assert_eq!(
668 tree.active_overlays().len(),
669 1,
670 "sticky composite tooltip should survive pointer-leave"
671 );
672 }
673
674 #[test]
675 fn composite_tooltip_preserves_children_so_body_survives_rebuild() {
676 let w = CompositeTooltipWidget::new().content(TextWidget::new(lit!("Body")));
682 assert!(
683 w.preserves_children_on_rebuild(),
684 "composite must preserve children so the reused body id stays valid across rebuild"
685 );
686 }
687
688 #[test]
694 fn a_non_sticky_composite_tooltip_never_promotes() {
695 let mut tree = tree_with_backend();
696 let tooltip_id_sink = Rc::new(Cell::new(None));
697 let host = tree.add(ComposeTooltipHost::new_non_sticky(tooltip_id_sink.clone()));
698 tree.layout(SizeProposal::exact(400.0, 200.0));
699
700 tree.pointer_move(tree.bounds(host).center());
701 tree.advance_time(Duration::from_millis(750));
702 assert_eq!(
703 tree.active_overlays().len(),
704 1,
705 "it still shows on hover — only the promotion is gone"
706 );
707
708 tree.advance_time(crate::tooltip::rich::DWELL_PROMOTION * 3);
710 tree.pointer_move(teksilo_canvas::Point::new(-100.0, -100.0));
711 tree.advance_time(Duration::from_millis(300));
712 assert!(
713 tree.active_overlays().is_empty(),
714 "a non-sticky surface must retire with the pointer, not survive it"
715 );
716 }
717
718 #[test]
720 fn a_non_sticky_composite_tooltip_is_shorter_than_a_sticky_one() {
721 let measure = |sticky: bool| {
722 let mut tree = WidgetTree::new()
723 .with_theme(teksilo_core::presets::intui::light())
724 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
725 tree.add_boxed(Box::new(
726 CompositeTooltipWidget::new()
727 .content(TextWidget::new(lit!("Hi")))
728 .sticky(sticky),
729 ));
730 tree.layout(SizeProposal::exact(1200.0, 900.0));
731 tree.measure_root_intrinsic(SizeProposal {
732 width: Some(1200.0),
733 height: Some(900.0),
734 })
735 .expect("a size")
736 .height
737 };
738 let sticky = measure(true);
739 let plain = measure(false);
740 assert!(
741 plain < sticky,
742 "without the indicator the surface should hug tighter \
743 (non-sticky {plain}, sticky {sticky})"
744 );
745 }
746
747 #[test]
755 fn the_dwell_indicator_sits_inside_the_tooltip_surface() {
756 let mut tree = WidgetTree::new()
757 .with_theme(teksilo_core::presets::intui::light())
758 .with_text_backend(Rc::new(RefCell::new(MockTextBackend::new())));
759 let tip = tree.add_boxed(Box::new(
760 CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
761 ));
762 let want = tree
766 .measure_root_intrinsic(SizeProposal {
767 width: Some(1200.0),
768 height: Some(900.0),
769 })
770 .expect("the tooltip reports a size");
771 tree.layout(SizeProposal::exact(want.width, want.height));
772
773 let surface = tree.bounds(tip);
774 let mut stack = tree.children(tip);
776 let mut worst: Option<(f32, f32)> = None;
777 while let Some(id) = stack.pop() {
778 let b = tree.bounds(id);
779 if b.height > 0.0 && b.y + b.height > surface.y + surface.height + 0.5 {
780 let overflow = (b.y + b.height) - (surface.y + surface.height);
781 if worst.is_none_or(|(w, _)| overflow > w) {
782 worst = Some((overflow, b.y + b.height));
783 }
784 }
785 stack.extend(tree.children(id));
786 }
787 assert!(
788 worst.is_none(),
789 "a descendant spills {:.1}dp below the tooltip surface \
790 (surface ends at {:.1}, child at {:.1}) — the dwell indicator is \
791 painted outside the bubble",
792 worst.unwrap().0,
793 surface.y + surface.height,
794 worst.unwrap().1,
795 );
796 }
797
798 #[test]
806 fn a_short_composite_tooltip_hugs_its_content() {
807 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
808 tree.add_boxed(Box::new(
809 CompositeTooltipWidget::new().content(TextWidget::new(lit!("Hi"))),
810 ));
811 tree.layout(SizeProposal::exact(1200.0, 900.0));
815 let s = tree
816 .measure_root_intrinsic(SizeProposal {
817 width: Some(1200.0),
818 height: Some(900.0),
819 })
820 .expect("the tooltip reports a size");
821 assert!(
822 s.width < 200.0,
823 "a two-letter body should not ask for a {}dp-wide tooltip",
824 s.width
825 );
826 assert!(
827 s.height < 120.0,
828 "a one-line body should not ask for a {}dp-tall tooltip",
829 s.height
830 );
831 }
832
833 #[test]
835 fn a_long_composite_tooltip_is_capped_by_its_maximum() {
836 let long = "word ".repeat(400);
837 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
838 tree.add_boxed(Box::new(
839 CompositeTooltipWidget::new()
840 .content(TextWidget::new(lit!(long)))
841 .max_width(240.0)
842 .max_height(160.0),
843 ));
844 tree.layout(SizeProposal::exact(1200.0, 900.0));
845 let s = tree
846 .measure_root_intrinsic(SizeProposal {
847 width: Some(1200.0),
848 height: Some(900.0),
849 })
850 .expect("the tooltip reports a size");
851 assert!(s.width <= 240.5, "width {} exceeds its maximum", s.width);
852 assert!(s.height <= 160.5, "height {} exceeds its maximum", s.height);
853 }
854}