1use crate::theme::Theme;
2use crate::tokens::{ColorPalette, DEFAULT_MOTION, ease_out_cubic, mix};
3use egui::epaint::Shadow;
4use egui::{
5 Color32, CornerRadius, Frame, Id, Order, Pos2, Rect, Response, Stroke, Ui, Vec2, WidgetText,
6 vec2,
7};
8use log::trace;
9use std::time::Duration;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
12pub enum TooltipPosition {
13 Cursor,
14 #[default]
15 Top,
16 Bottom,
17 Left,
18 Right,
19}
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
22pub enum TooltipSide {
23 #[default]
24 Top,
25 Right,
26 Bottom,
27 Left,
28}
29
30impl TooltipSide {
31 pub fn from_position(pos: TooltipPosition) -> Self {
32 match pos {
33 TooltipPosition::Top => TooltipSide::Top,
34 TooltipPosition::Bottom => TooltipSide::Bottom,
35 TooltipPosition::Left => TooltipSide::Left,
36 TooltipPosition::Right => TooltipSide::Right,
37 TooltipPosition::Cursor => TooltipSide::Top,
38 }
39 }
40
41 pub fn offset_direction(&self) -> Vec2 {
42 match self {
43 TooltipSide::Top => vec2(0.0, -1.0),
44 TooltipSide::Bottom => vec2(0.0, 1.0),
45 TooltipSide::Left => vec2(-1.0, 0.0),
46 TooltipSide::Right => vec2(1.0, 0.0),
47 }
48 }
49
50 pub fn flip(&self) -> Self {
51 match self {
52 TooltipSide::Top => TooltipSide::Bottom,
53 TooltipSide::Bottom => TooltipSide::Top,
54 TooltipSide::Left => TooltipSide::Right,
55 TooltipSide::Right => TooltipSide::Left,
56 }
57 }
58
59 pub fn is_vertical(&self) -> bool {
60 matches!(self, TooltipSide::Top | TooltipSide::Bottom)
61 }
62}
63
64#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
65pub enum TooltipAlign {
66 #[default]
67 Center,
68 Start,
69 End,
70}
71
72impl TooltipAlign {
73 pub fn factor(&self) -> f32 {
74 match self {
75 TooltipAlign::Center => 0.0,
76 TooltipAlign::Start => -1.0,
77 TooltipAlign::End => 1.0,
78 }
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
83pub enum TooltipSticky {
84 #[default]
85 Partial,
86 Always,
87}
88
89impl From<bool> for TooltipSticky {
90 fn from(value: bool) -> Self {
91 if value {
92 TooltipSticky::Always
93 } else {
94 TooltipSticky::Partial
95 }
96 }
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
100pub enum TooltipUpdatePositionStrategy {
101 #[default]
102 Optimized,
103 Always,
104}
105
106#[derive(Clone, Copy, Debug, PartialEq)]
107pub struct TooltipCollisionPadding {
108 pub top: f32,
109 pub right: f32,
110 pub bottom: f32,
111 pub left: f32,
112}
113
114impl TooltipCollisionPadding {
115 pub fn all(value: f32) -> Self {
116 Self {
117 top: value,
118 right: value,
119 bottom: value,
120 left: value,
121 }
122 }
123}
124
125impl Default for TooltipCollisionPadding {
126 fn default() -> Self {
127 Self::all(10.0)
128 }
129}
130
131impl From<f32> for TooltipCollisionPadding {
132 fn from(value: f32) -> Self {
133 TooltipCollisionPadding::all(value)
134 }
135}
136
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum TooltipPortalContainer {
139 Tooltip,
140 Foreground,
141 Middle,
142 Background,
143}
144
145impl TooltipPortalContainer {
146 fn order(self) -> Order {
147 match self {
148 TooltipPortalContainer::Tooltip => Order::Tooltip,
149 TooltipPortalContainer::Foreground => Order::Foreground,
150 TooltipPortalContainer::Middle => Order::Middle,
151 TooltipPortalContainer::Background => Order::Background,
152 }
153 }
154}
155
156#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
157pub struct TooltipPreventable {
158 default_prevented: bool,
159}
160
161impl TooltipPreventable {
162 pub fn prevent_default(&mut self) {
163 self.default_prevented = true;
164 }
165
166 pub fn default_prevented(&self) -> bool {
167 self.default_prevented
168 }
169}
170
171#[derive(Clone, Copy, Debug, PartialEq)]
172pub struct TooltipEscapeKeyDownEvent {
173 pub key: egui::Key,
174 pub preventable: TooltipPreventable,
175}
176
177#[derive(Clone, Copy, Debug, PartialEq)]
178pub struct TooltipPointerDownOutsideEvent {
179 pub pointer_pos: Option<Pos2>,
180 pub preventable: TooltipPreventable,
181}
182
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum TooltipAnimationState {
185 Closed,
186
187 DelayedOpen,
188
189 InstantOpen,
190}
191
192#[derive(Clone, Debug, Default)]
193pub struct TooltipOpenState {
194 pub is_open: bool,
195
196 pub animation_progress: f32,
197
198 pub hover_start_time: Option<f64>,
199
200 pub last_close_time: Option<f64>,
201}
202
203impl TooltipOpenState {
204 pub fn is_visible(&self) -> bool {
205 self.is_open || self.animation_progress > 0.0
206 }
207
208 pub fn is_animating(&self) -> bool {
209 if self.is_open {
210 self.animation_progress < 1.0
211 } else {
212 self.animation_progress > 0.0
213 }
214 }
215
216 pub fn should_skip_delay(&self, current_time: f64, skip_delay_ms: u64) -> bool {
217 if let Some(close_time) = self.last_close_time {
218 let elapsed = current_time - close_time;
219 let skip_delay_secs = skip_delay_ms as f64 / 1000.0;
220 elapsed < skip_delay_secs
221 } else {
222 false
223 }
224 }
225}
226
227#[derive(Clone, Debug, Default)]
228pub struct TooltipState {
229 pub open_state: TooltipOpenState,
230
231 pub computed_side: Option<TooltipSide>,
232
233 pub computed_align: Option<TooltipAlign>,
234}
235
236impl TooltipState {
237 pub fn new() -> Self {
238 Self::default()
239 }
240}
241
242#[derive(Clone, Debug)]
243pub struct TooltipStyle {
244 pub bg: Color32,
245 pub border: Color32,
246 pub border_width: f32,
247 pub text: Color32,
248 pub rounding: CornerRadius,
249 pub shadow: Shadow,
250
251 pub arrow_fill: Color32,
252}
253
254impl TooltipStyle {
255 pub fn from_palette(palette: &ColorPalette, high_contrast: bool) -> Self {
256 let bg = if high_contrast {
257 palette.foreground
258 } else {
259 mix(palette.foreground, palette.background, 0.1)
260 };
261
262 let border = if high_contrast {
263 palette.foreground
264 } else {
265 mix(palette.border, palette.foreground, 0.2)
266 };
267
268 let text = palette.background;
269
270 let rounding = CornerRadius::same(6);
271 let shadow = Shadow::default();
272 Self {
273 bg,
274 border,
275 border_width: if high_contrast { 0.0 } else { 1.0 },
276 text,
277 rounding,
278 shadow,
279 arrow_fill: bg,
280 }
281 }
282}
283
284pub struct TooltipProps<'a> {
285 pub text: WidgetText,
286
287 pub delay_ms: u64,
288 pub skip_delay_ms: u64,
289
290 pub max_width: f32,
291
292 pub position: TooltipPosition,
293
294 pub side: TooltipSide,
295
296 pub align: TooltipAlign,
297
298 pub offset: Vec2,
299
300 pub side_offset: f32,
301
302 pub align_offset: f32,
303
304 pub collision_padding: TooltipCollisionPadding,
305
306 pub collision_boundary: Option<Rect>,
307
308 pub aria_label: Option<String>,
309
310 pub high_contrast: bool,
311 pub persistent_id: Option<Id>,
312 pub style: Option<TooltipStyle>,
313 pub show_when_disabled: bool,
314
315 pub show_arrow: bool,
316
317 pub arrow_width: f32,
318
319 pub arrow_height: f32,
320
321 pub arrow_padding: f32,
322
323 pub sticky: TooltipSticky,
324
325 pub hide_when_detached: bool,
326
327 pub update_position_strategy: TooltipUpdatePositionStrategy,
328
329 pub container: Option<TooltipPortalContainer>,
330
331 pub force_mount: bool,
332
333 pub disable_hoverable_content: bool,
334
335 pub animation_duration_ms: u64,
336
337 pub open: Option<bool>,
338
339 pub default_open: bool,
340
341 pub avoid_collisions: bool,
342
343 pub on_open_change: Option<&'a mut dyn FnMut(bool)>,
344
345 pub on_escape_key_down: Option<&'a mut dyn FnMut(&mut TooltipEscapeKeyDownEvent)>,
346
347 pub on_pointer_down_outside: Option<&'a mut dyn FnMut(&mut TooltipPointerDownOutsideEvent)>,
348}
349
350impl std::fmt::Debug for TooltipProps<'_> {
351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
352 f.debug_struct("TooltipProps")
353 .field("text", &self.text.text())
354 .field("delay_ms", &self.delay_ms)
355 .field("skip_delay_ms", &self.skip_delay_ms)
356 .field("max_width", &self.max_width)
357 .field("position", &self.position)
358 .field("side", &self.side)
359 .field("align", &self.align)
360 .field("offset", &self.offset)
361 .field("side_offset", &self.side_offset)
362 .field("align_offset", &self.align_offset)
363 .field("collision_padding", &self.collision_padding)
364 .field("collision_boundary", &self.collision_boundary)
365 .field("aria_label", &self.aria_label)
366 .field("high_contrast", &self.high_contrast)
367 .field("persistent_id", &self.persistent_id)
368 .field("style", &self.style.is_some())
369 .field("show_when_disabled", &self.show_when_disabled)
370 .field("show_arrow", &self.show_arrow)
371 .field("arrow_width", &self.arrow_width)
372 .field("arrow_height", &self.arrow_height)
373 .field("arrow_padding", &self.arrow_padding)
374 .field("sticky", &self.sticky)
375 .field("hide_when_detached", &self.hide_when_detached)
376 .field("update_position_strategy", &self.update_position_strategy)
377 .field("container", &self.container)
378 .field("force_mount", &self.force_mount)
379 .field("disable_hoverable_content", &self.disable_hoverable_content)
380 .field("animation_duration_ms", &self.animation_duration_ms)
381 .field("open", &self.open)
382 .field("default_open", &self.default_open)
383 .field("avoid_collisions", &self.avoid_collisions)
384 .field("on_open_change", &self.on_open_change.is_some())
385 .field("on_escape_key_down", &self.on_escape_key_down.is_some())
386 .field(
387 "on_pointer_down_outside",
388 &self.on_pointer_down_outside.is_some(),
389 )
390 .finish()
391 }
392}
393
394impl<'a> TooltipProps<'a> {
395 pub fn new(text: impl Into<WidgetText>) -> Self {
396 Self {
397 text: text.into(),
398 delay_ms: 700,
399 skip_delay_ms: 300,
400 max_width: 360.0,
401 position: TooltipPosition::Top,
402 side: TooltipSide::Top,
403 align: TooltipAlign::Center,
404 offset: vec2(0.0, 8.0),
405 side_offset: 4.0,
406 align_offset: 0.0,
407 collision_padding: TooltipCollisionPadding::default(),
408 collision_boundary: None,
409 aria_label: None,
410 high_contrast: false,
411 persistent_id: None,
412 style: None,
413 show_when_disabled: false,
414 show_arrow: false,
415 arrow_width: 11.0,
416 arrow_height: 5.0,
417 arrow_padding: 0.0,
418 sticky: TooltipSticky::default(),
419 hide_when_detached: false,
420 update_position_strategy: TooltipUpdatePositionStrategy::default(),
421 container: None,
422 force_mount: false,
423 disable_hoverable_content: false,
424 animation_duration_ms: DEFAULT_MOTION.base_ms as u64,
425 open: None,
426 default_open: false,
427 avoid_collisions: true,
428 on_open_change: None,
429 on_escape_key_down: None,
430 on_pointer_down_outside: None,
431 }
432 }
433
434 pub fn delay_ms(mut self, delay_ms: u64) -> Self {
435 self.delay_ms = delay_ms;
436 self
437 }
438
439 pub fn delay_duration(self, delay_ms: u64) -> Self {
440 self.delay_ms(delay_ms)
441 }
442
443 pub fn max_width(mut self, max_width: f32) -> Self {
444 self.max_width = max_width;
445 self
446 }
447
448 pub fn position(mut self, position: TooltipPosition) -> Self {
449 self.position = position;
450 self.side = TooltipSide::from_position(position);
451 self
452 }
453
454 pub fn side(mut self, side: TooltipSide) -> Self {
455 self.side = side;
456 self
457 }
458
459 pub fn align(mut self, align: TooltipAlign) -> Self {
460 self.align = align;
461 self
462 }
463
464 pub fn offset(mut self, offset: Vec2) -> Self {
465 self.offset = offset;
466 self
467 }
468
469 pub fn side_offset(mut self, offset: f32) -> Self {
470 self.side_offset = offset;
471 self
472 }
473
474 pub fn align_offset(mut self, offset: f32) -> Self {
475 self.align_offset = offset;
476 self
477 }
478
479 pub fn collision_padding(mut self, padding: impl Into<TooltipCollisionPadding>) -> Self {
480 self.collision_padding = padding.into();
481 self
482 }
483
484 pub fn collision_boundary(mut self, boundary: Rect) -> Self {
485 self.collision_boundary = Some(boundary);
486 self
487 }
488
489 pub fn aria_label(mut self, aria_label: impl Into<String>) -> Self {
490 self.aria_label = Some(aria_label.into());
491 self
492 }
493
494 pub fn high_contrast(mut self, high_contrast: bool) -> Self {
495 self.high_contrast = high_contrast;
496 self
497 }
498
499 pub fn persistent_id(mut self, id: Id) -> Self {
500 self.persistent_id = Some(id);
501 self
502 }
503
504 pub fn style(mut self, style: TooltipStyle) -> Self {
505 self.style = Some(style);
506 self
507 }
508
509 pub fn show_when_disabled(mut self, show: bool) -> Self {
510 self.show_when_disabled = show;
511 self
512 }
513
514 pub fn show_arrow(mut self, show: bool) -> Self {
515 self.show_arrow = show;
516 self
517 }
518
519 pub fn arrow_size(mut self, width: f32, height: f32) -> Self {
520 self.arrow_width = width;
521 self.arrow_height = height;
522 self
523 }
524
525 pub fn arrow_padding(mut self, padding: f32) -> Self {
526 self.arrow_padding = padding;
527 self
528 }
529
530 pub fn force_mount(mut self, force: bool) -> Self {
531 self.force_mount = force;
532 self
533 }
534
535 pub fn skip_delay_ms(mut self, skip_delay: u64) -> Self {
536 self.skip_delay_ms = skip_delay;
537 self
538 }
539
540 pub fn skip_delay_duration(self, skip_delay_ms: u64) -> Self {
541 self.skip_delay_ms(skip_delay_ms)
542 }
543
544 pub fn disable_hoverable_content(mut self, disable: bool) -> Self {
545 self.disable_hoverable_content = disable;
546 self
547 }
548
549 pub fn animation_duration_ms(mut self, duration: u64) -> Self {
550 self.animation_duration_ms = duration;
551 self
552 }
553
554 pub fn open(mut self, open: bool) -> Self {
555 self.open = Some(open);
556 self
557 }
558
559 pub fn default_open(mut self, default_open: bool) -> Self {
560 self.default_open = default_open;
561 self
562 }
563
564 pub fn on_open_change(mut self, on_open_change: &'a mut dyn FnMut(bool)) -> Self {
565 self.on_open_change = Some(on_open_change);
566 self
567 }
568
569 pub fn sticky(mut self, sticky: impl Into<TooltipSticky>) -> Self {
570 self.sticky = sticky.into();
571 self
572 }
573
574 pub fn sticky_enabled(mut self, enabled: bool) -> Self {
575 self.sticky = if enabled {
576 TooltipSticky::Always
577 } else {
578 TooltipSticky::Partial
579 };
580 self
581 }
582
583 pub fn hide_when_detached(mut self, hide_when_detached: bool) -> Self {
584 self.hide_when_detached = hide_when_detached;
585 self
586 }
587
588 pub fn update_position_strategy(
589 mut self,
590 update_position_strategy: TooltipUpdatePositionStrategy,
591 ) -> Self {
592 self.update_position_strategy = update_position_strategy;
593 self
594 }
595
596 pub fn container(mut self, container: TooltipPortalContainer) -> Self {
597 self.container = Some(container);
598 self
599 }
600
601 pub fn on_escape_key_down(
602 mut self,
603 on_escape_key_down: &'a mut dyn FnMut(&mut TooltipEscapeKeyDownEvent),
604 ) -> Self {
605 self.on_escape_key_down = Some(on_escape_key_down);
606 self
607 }
608
609 pub fn on_pointer_down_outside(
610 mut self,
611 on_pointer_down_outside: &'a mut dyn FnMut(&mut TooltipPointerDownOutsideEvent),
612 ) -> Self {
613 self.on_pointer_down_outside = Some(on_pointer_down_outside);
614 self
615 }
616
617 pub fn avoid_collisions(mut self, avoid: bool) -> Self {
618 self.avoid_collisions = avoid;
619 self
620 }
621}
622
623#[allow(clippy::too_many_arguments)]
624fn calculate_tooltip_pos(
625 anchor_rect: Rect,
626 tooltip_size: Vec2,
627 side: TooltipSide,
628 align: TooltipAlign,
629 side_offset: f32,
630 align_offset: f32,
631 collision_padding: TooltipCollisionPadding,
632 collision_boundary: Rect,
633 avoid_collisions: bool,
634 arrow_height: f32,
635 show_arrow: bool,
636) -> (Pos2, TooltipSide) {
637 let effective_side_offset = if show_arrow {
638 side_offset + arrow_height
639 } else {
640 side_offset
641 };
642
643 let mut current_side = side;
644 let mut pos = calculate_position_for_side(
645 anchor_rect,
646 tooltip_size,
647 current_side,
648 align,
649 effective_side_offset,
650 align_offset,
651 );
652
653 let viewport_rect = collision_boundary;
654 let padded_viewport = Rect::from_min_max(
655 Pos2::new(
656 viewport_rect.left() + collision_padding.left,
657 viewport_rect.top() + collision_padding.top,
658 ),
659 Pos2::new(
660 viewport_rect.right() - collision_padding.right,
661 viewport_rect.bottom() - collision_padding.bottom,
662 ),
663 );
664
665 if avoid_collisions {
666 let tooltip_rect = Rect::from_min_size(pos, tooltip_size);
667
668 if !padded_viewport.contains_rect(tooltip_rect) {
669 let flipped_side = current_side.flip();
670 let flipped_pos = calculate_position_for_side(
671 anchor_rect,
672 tooltip_size,
673 flipped_side,
674 align,
675 effective_side_offset,
676 align_offset,
677 );
678 let flipped_rect = Rect::from_min_size(flipped_pos, tooltip_size);
679
680 if padded_viewport.contains_rect(flipped_rect) {
681 current_side = flipped_side;
682 pos = flipped_pos;
683 }
684 }
685 }
686
687 let min_x = padded_viewport.left();
688 let max_x = (padded_viewport.right() - tooltip_size.x).max(min_x);
689 let min_y = padded_viewport.top();
690 let max_y = (padded_viewport.bottom() - tooltip_size.y).max(min_y);
691
692 pos.x = pos.x.clamp(min_x, max_x);
693 pos.y = pos.y.clamp(min_y, max_y);
694
695 (pos, current_side)
696}
697
698fn calculate_position_for_side(
699 anchor_rect: Rect,
700 tooltip_size: Vec2,
701 side: TooltipSide,
702 align: TooltipAlign,
703 side_offset: f32,
704 align_offset: f32,
705) -> Pos2 {
706 let anchor_center = anchor_rect.center();
707
708 match side {
709 TooltipSide::Top => {
710 let x = calculate_aligned_pos(
711 anchor_center.x,
712 anchor_rect.width(),
713 tooltip_size.x,
714 align,
715 align_offset,
716 );
717 let y = anchor_rect.top() - tooltip_size.y - side_offset;
718 Pos2::new(x, y)
719 }
720 TooltipSide::Bottom => {
721 let x = calculate_aligned_pos(
722 anchor_center.x,
723 anchor_rect.width(),
724 tooltip_size.x,
725 align,
726 align_offset,
727 );
728 let y = anchor_rect.bottom() + side_offset;
729 Pos2::new(x, y)
730 }
731 TooltipSide::Left => {
732 let x = anchor_rect.left() - tooltip_size.x - side_offset;
733 let y = calculate_aligned_pos(
734 anchor_center.y,
735 anchor_rect.height(),
736 tooltip_size.y,
737 align,
738 align_offset,
739 );
740 Pos2::new(x, y)
741 }
742 TooltipSide::Right => {
743 let x = anchor_rect.right() + side_offset;
744 let y = calculate_aligned_pos(
745 anchor_center.y,
746 anchor_rect.height(),
747 tooltip_size.y,
748 align,
749 align_offset,
750 );
751 Pos2::new(x, y)
752 }
753 }
754}
755
756fn calculate_aligned_pos(
757 anchor_center: f32,
758 anchor_size: f32,
759 tooltip_size: f32,
760 align: TooltipAlign,
761 align_offset: f32,
762) -> f32 {
763 match align {
764 TooltipAlign::Center => anchor_center - tooltip_size / 2.0 + align_offset,
765 TooltipAlign::Start => anchor_center - anchor_size / 2.0 + align_offset,
766 TooltipAlign::End => anchor_center + anchor_size / 2.0 - tooltip_size + align_offset,
767 }
768}
769
770#[allow(clippy::too_many_arguments)]
771fn draw_arrow(
772 painter: &egui::Painter,
773 content_rect: Rect,
774 side: TooltipSide,
775 arrow_width: f32,
776 arrow_height: f32,
777 fill: Color32,
778 anchor_rect: Rect,
779 arrow_padding: f32,
780) {
781 let arrow_center = match side {
782 TooltipSide::Top | TooltipSide::Bottom => {
783 let min_x = content_rect.left() + arrow_padding + arrow_width / 2.0;
784 let max_x = content_rect.right() - arrow_padding - arrow_width / 2.0;
785 anchor_rect.center().x.clamp(min_x, max_x)
786 }
787 TooltipSide::Left | TooltipSide::Right => {
788 let min_y = content_rect.top() + arrow_padding + arrow_width / 2.0;
789 let max_y = content_rect.bottom() - arrow_padding - arrow_width / 2.0;
790 anchor_rect.center().y.clamp(min_y, max_y)
791 }
792 };
793
794 let points = match side {
795 TooltipSide::Top => {
796 let tip_y = content_rect.bottom() + arrow_height;
797 vec![
798 Pos2::new(arrow_center - arrow_width / 2.0, content_rect.bottom()),
799 Pos2::new(arrow_center + arrow_width / 2.0, content_rect.bottom()),
800 Pos2::new(arrow_center, tip_y),
801 ]
802 }
803 TooltipSide::Bottom => {
804 let tip_y = content_rect.top() - arrow_height;
805 vec![
806 Pos2::new(arrow_center - arrow_width / 2.0, content_rect.top()),
807 Pos2::new(arrow_center + arrow_width / 2.0, content_rect.top()),
808 Pos2::new(arrow_center, tip_y),
809 ]
810 }
811 TooltipSide::Left => {
812 let tip_x = content_rect.right() + arrow_height;
813 vec![
814 Pos2::new(content_rect.right(), arrow_center - arrow_width / 2.0),
815 Pos2::new(content_rect.right(), arrow_center + arrow_width / 2.0),
816 Pos2::new(tip_x, arrow_center),
817 ]
818 }
819 TooltipSide::Right => {
820 let tip_x = content_rect.left() - arrow_height;
821 vec![
822 Pos2::new(content_rect.left(), arrow_center - arrow_width / 2.0),
823 Pos2::new(content_rect.left(), arrow_center + arrow_width / 2.0),
824 Pos2::new(tip_x, arrow_center),
825 ]
826 }
827 };
828
829 let shape = egui::epaint::PathShape::convex_polygon(points, fill, Stroke::NONE);
830 painter.add(shape);
831}
832
833fn get_global_last_close_time(ctx: &egui::Context) -> Option<f64> {
834 ctx.data(|d| d.get_temp::<f64>(Id::new("__tooltip_global_last_close__")))
835}
836
837fn set_global_last_close_time(ctx: &egui::Context, time: f64) {
838 ctx.data_mut(|d| d.insert_temp(Id::new("__tooltip_global_last_close__"), time));
839}
840
841pub fn tooltip(anchor: &Response, ui: &mut Ui, theme: &Theme, mut props: TooltipProps<'_>) -> bool {
842 let ctx = ui.ctx();
843 let now = ctx.input(|i| i.time);
844
845 let anchor_hovered = anchor.hovered() || anchor.has_focus();
846 let disabled = !anchor.enabled();
847
848 if disabled && !props.show_when_disabled && !props.force_mount {
849 return false;
850 }
851
852 let id = props
853 .persistent_id
854 .unwrap_or_else(|| anchor.id.with("tooltip"));
855
856 let anchor_rect = anchor.rect;
857 let collision_boundary = props
858 .collision_boundary
859 .unwrap_or_else(|| ctx.viewport_rect());
860 if props.hide_when_detached && !collision_boundary.intersects(anchor_rect) && !props.force_mount
861 {
862 return false;
863 }
864
865 let delay_secs = props.delay_ms as f64 / 1000.0;
866 let animation_duration = (props.animation_duration_ms as f32).max(1.0) / 1000.0;
867
868 let global_last_close = get_global_last_close_time(ctx);
869 let should_skip_delay = global_last_close.is_some_and(|close_time| {
870 let elapsed = now - close_time;
871 elapsed < (props.skip_delay_ms as f64 / 1000.0)
872 });
873
874 let tooltip_area_id = id.with("area");
875 let tooltip_hovered = if !props.disable_hoverable_content {
876 ctx.data(|d| d.get_temp::<bool>(tooltip_area_id))
877 .unwrap_or(false)
878 } else {
879 false
880 };
881
882 let want_open = anchor_hovered || tooltip_hovered;
883
884 let is_controlled = props.open.is_some();
885 let controlled_open = props.open.unwrap_or(false);
886
887 let init_key = id.with("default-open-initialized");
888 let hover_start_key = id.with("hover-start");
889 let internal_open_key = id.with("is-open");
890 let last_request_key = id.with("last-open-request");
891 let last_visible_key = id.with("last-visible-open");
892
893 let (elapsed_hover, internal_open_before, internal_open_after, requested_open, applied_default) =
894 ctx.data_mut(|d| {
895 let internal_before = d.get_temp::<bool>(internal_open_key).unwrap_or(false);
896 let mut internal_after = internal_before;
897 let mut requested = false;
898 let mut elapsed_hover = 0.0;
899 let mut applied_default_open = false;
900
901 let initialized = d.get_temp::<bool>(init_key).unwrap_or(false);
902 if !initialized {
903 d.insert_temp(init_key, true);
904 if props.default_open {
905 applied_default_open = true;
906 requested = true;
907 if !is_controlled {
908 internal_after = true;
909 d.insert_temp(internal_open_key, true);
910 }
911 }
912 }
913
914 if want_open && d.get_temp::<f64>(hover_start_key).is_none() {
915 d.insert_temp(hover_start_key, now);
916 }
917 if want_open {
918 let start = d.get_temp::<f64>(hover_start_key).unwrap_or(now);
919 elapsed_hover = now - start;
920 } else {
921 d.remove::<f64>(hover_start_key);
922 if !applied_default_open {
923 requested = false;
924 }
925 }
926
927 if !applied_default_open {
928 let effective_delay = if should_skip_delay { 0.0 } else { delay_secs };
929 requested = want_open && elapsed_hover >= effective_delay;
930 }
931
932 if !is_controlled {
933 if requested {
934 d.insert_temp(internal_open_key, true);
935 internal_after = true;
936 } else {
937 d.remove::<bool>(internal_open_key);
938 internal_after = false;
939 }
940 }
941
942 (
943 elapsed_hover,
944 internal_before,
945 internal_after,
946 requested,
947 applied_default_open,
948 )
949 });
950
951 let render_open = if is_controlled {
952 controlled_open
953 } else {
954 internal_open_after
955 };
956
957 if is_controlled {
958 let last_requested = ctx
959 .data(|d| d.get_temp::<bool>(last_request_key))
960 .unwrap_or(controlled_open);
961 if requested_open != last_requested {
962 ctx.data_mut(|d| d.insert_temp(last_request_key, requested_open));
963 if requested_open != controlled_open
964 && let Some(cb) = props.on_open_change.as_mut()
965 {
966 cb(requested_open);
967 }
968 }
969 } else if internal_open_after != internal_open_before
970 && let Some(cb) = props.on_open_change.as_mut()
971 {
972 cb(internal_open_after);
973 }
974
975 if internal_open_before && !internal_open_after && !is_controlled {
976 set_global_last_close_time(ctx, now);
977 }
978
979 let last_visible = ctx
980 .data(|d| d.get_temp::<bool>(last_visible_key))
981 .unwrap_or(false);
982 if last_visible && !render_open {
983 set_global_last_close_time(ctx, now);
984 }
985 ctx.data_mut(|d| d.insert_temp(last_visible_key, render_open));
986
987 let animation_progress = ctx.animate_bool_with_time_and_easing(
988 id.with("animation"),
989 render_open,
990 animation_duration,
991 ease_out_cubic,
992 );
993
994 if animation_progress <= 0.0 && !props.force_mount {
995 if (want_open || applied_default) && elapsed_hover < delay_secs && !should_skip_delay {
996 ctx.request_repaint_after(Duration::from_secs_f64(delay_secs - elapsed_hover));
997 }
998 return false;
999 }
1000
1001 let style = props
1002 .style
1003 .clone()
1004 .unwrap_or_else(|| TooltipStyle::from_palette(&theme.palette, props.high_contrast));
1005
1006 let (measured_size, text_galley) = {
1007 let text_str = props.text.text().to_string();
1008 let available_width = props.max_width - 24.0;
1009
1010 let galley = ctx.fonts_mut(|fonts| {
1011 fonts.layout(
1012 text_str,
1013 egui::FontId::default(),
1014 style.text,
1015 available_width,
1016 )
1017 });
1018
1019 let text_size = galley.size();
1020 let size = Vec2::new(text_size.x + 24.0, text_size.y + 12.0);
1021
1022 (size, galley)
1023 };
1024
1025 let _ = text_galley;
1026
1027 let (tooltip_pos, computed_side) = calculate_tooltip_pos(
1028 anchor_rect,
1029 measured_size,
1030 props.side,
1031 props.align,
1032 props.side_offset,
1033 props.align_offset,
1034 props.collision_padding,
1035 collision_boundary,
1036 props.avoid_collisions,
1037 props.arrow_height,
1038 props.show_arrow,
1039 );
1040
1041 let slide_offset = match computed_side {
1042 TooltipSide::Top => vec2(0.0, 4.0),
1043 TooltipSide::Bottom => vec2(0.0, -4.0),
1044 TooltipSide::Left => vec2(4.0, 0.0),
1045 TooltipSide::Right => vec2(-4.0, 0.0),
1046 };
1047
1048 let scale = 0.96 + 0.04 * animation_progress;
1049 let scaled_size = measured_size * scale;
1050 let scale_offset = (measured_size - scaled_size) * 0.5;
1051
1052 let animated_offset = slide_offset * (1.0 - animation_progress);
1053 let final_pos = tooltip_pos + animated_offset + scale_offset;
1054
1055 let opacity = animation_progress;
1056
1057 trace!(
1058 "Showing tooltip at {:?}, side={:?}, progress={:.2}",
1059 final_pos, computed_side, animation_progress
1060 );
1061
1062 let order = props
1063 .container
1064 .unwrap_or(TooltipPortalContainer::Tooltip)
1065 .order();
1066
1067 let area_response = egui::Area::new(id)
1068 .order(order)
1069 .interactable(render_open)
1070 .fixed_pos(final_pos)
1071 .show(ctx, |tooltip_ui| {
1072 tooltip_ui.set_max_width(props.max_width);
1073
1074 let mut visuals = tooltip_ui.visuals().clone();
1075 visuals.widgets.noninteractive.bg_fill = style.bg.gamma_multiply(opacity);
1076 tooltip_ui.ctx().set_visuals(visuals);
1077
1078 let mut frame = Frame::popup(tooltip_ui.style());
1079 frame.fill = style.bg.gamma_multiply(opacity);
1080 frame.stroke = Stroke::new(style.border_width, style.border.gamma_multiply(opacity));
1081 frame.corner_radius = style.rounding;
1082 frame.shadow = Shadow {
1083 offset: style.shadow.offset,
1084 blur: style.shadow.blur,
1085 spread: style.shadow.spread,
1086 color: style.shadow.color.gamma_multiply(opacity),
1087 };
1088 frame.inner_margin = egui::Margin::symmetric(12, 6);
1089
1090 let frame_response = frame.show(tooltip_ui, |content_ui| {
1091 content_ui.style_mut().visuals.override_text_color =
1092 Some(style.text.gamma_multiply(opacity));
1093
1094 content_ui.label(props.text.clone().color(style.text.gamma_multiply(opacity)));
1095 });
1096
1097 if props.show_arrow {
1098 let painter = tooltip_ui.painter();
1099 draw_arrow(
1100 painter,
1101 frame_response.response.rect,
1102 computed_side,
1103 props.arrow_width,
1104 props.arrow_height,
1105 style.arrow_fill.gamma_multiply(opacity),
1106 anchor_rect,
1107 props.arrow_padding,
1108 );
1109 }
1110 });
1111
1112 let mut should_close = false;
1113
1114 if render_open && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
1115 let mut evt = TooltipEscapeKeyDownEvent {
1116 key: egui::Key::Escape,
1117 preventable: TooltipPreventable::default(),
1118 };
1119 if let Some(cb) = props.on_escape_key_down.as_mut() {
1120 cb(&mut evt);
1121 }
1122 if !evt.preventable.default_prevented() {
1123 should_close = true;
1124 }
1125 }
1126
1127 let tooltip_rect = area_response.response.rect;
1128 let (any_click, interact_pos) =
1129 ctx.input(|i| (i.pointer.any_click(), i.pointer.interact_pos()));
1130 if render_open
1131 && any_click
1132 && interact_pos.is_some_and(|pos| !tooltip_rect.contains(pos) && !anchor_rect.contains(pos))
1133 {
1134 let mut evt = TooltipPointerDownOutsideEvent {
1135 pointer_pos: interact_pos,
1136 preventable: TooltipPreventable::default(),
1137 };
1138 if let Some(cb) = props.on_pointer_down_outside.as_mut() {
1139 cb(&mut evt);
1140 }
1141 if !evt.preventable.default_prevented() {
1142 should_close = true;
1143 }
1144 }
1145
1146 if should_close {
1147 if is_controlled {
1148 if let Some(cb) = props.on_open_change.as_mut() {
1149 cb(false);
1150 }
1151 } else {
1152 let was_open = ctx
1153 .data(|d| d.get_temp::<bool>(internal_open_key))
1154 .unwrap_or(false);
1155 if was_open {
1156 ctx.data_mut(|d| d.remove::<bool>(internal_open_key));
1157 if let Some(cb) = props.on_open_change.as_mut() {
1158 cb(false);
1159 }
1160 set_global_last_close_time(ctx, now);
1161 }
1162 }
1163 }
1164
1165 if !props.disable_hoverable_content && render_open {
1166 let expanded_rect = tooltip_rect.expand(4.0);
1167 let mouse_pos = ctx.input(|i| i.pointer.hover_pos());
1168 let content_hovered = mouse_pos.is_some_and(|pos| expanded_rect.contains(pos));
1169 ctx.data_mut(|d| d.insert_temp(tooltip_area_id, content_hovered));
1170 }
1171
1172 if props.update_position_strategy == TooltipUpdatePositionStrategy::Always
1173 || (opacity > 0.0 && opacity < 1.0)
1174 {
1175 ctx.request_repaint();
1176 }
1177
1178 opacity > 0.0 || props.force_mount
1179}