Skip to main content

egui_shadcn/
tooltip.rs

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}