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