Skip to main content

egui/containers/
tooltip.rs

1use crate::pass_state::PerWidgetTooltipState;
2use crate::{
3    AreaState, Context, Id, InnerResponse, LayerId, Layout, Order, Popup, PopupAnchor, PopupKind,
4    Response, Sense,
5};
6use emath::Vec2;
7
8pub struct Tooltip<'a> {
9    pub popup: Popup<'a>,
10
11    /// The layer of the parent widget.
12    parent_layer: LayerId,
13
14    /// The id of the widget that owns this tooltip.
15    parent_widget: Id,
16}
17
18impl Tooltip<'_> {
19    /// Show a tooltip that is always open.
20    pub fn always_open(
21        ctx: Context,
22        parent_layer: LayerId,
23        parent_widget: Id,
24        anchor: impl Into<PopupAnchor>,
25    ) -> Self {
26        let width = ctx.global_style().spacing.tooltip_width;
27        Self {
28            popup: Popup::new(parent_widget, ctx, anchor.into(), parent_layer)
29                .kind(PopupKind::Tooltip)
30                .gap(4.0)
31                .width(width)
32                .sense(Sense::hover()),
33            parent_layer,
34            parent_widget,
35        }
36    }
37
38    /// Show a tooltip for a widget. Always open (as long as this function is called).
39    pub fn for_widget(response: &Response) -> Self {
40        let popup = Popup::from_response(response)
41            .kind(PopupKind::Tooltip)
42            .gap(4.0)
43            .width(response.ctx.global_style().spacing.tooltip_width)
44            .sense(Sense::hover());
45        Self {
46            popup,
47            parent_layer: response.layer_id,
48            parent_widget: response.id,
49        }
50    }
51
52    /// Show a tooltip when hovering an enabled widget.
53    pub fn for_enabled(response: &Response) -> Self {
54        let mut tooltip = Self::for_widget(response);
55        tooltip.popup = tooltip
56            .popup
57            .open(response.enabled() && Self::should_show_tooltip(response, true));
58        tooltip
59    }
60
61    /// Show a tooltip when hovering a disabled widget.
62    pub fn for_disabled(response: &Response) -> Self {
63        let mut tooltip = Self::for_widget(response);
64        tooltip.popup = tooltip
65            .popup
66            .open(!response.enabled() && Self::should_show_tooltip(response, true));
67        tooltip
68    }
69
70    /// Show the tooltip at the pointer position.
71    #[inline]
72    pub fn at_pointer(mut self) -> Self {
73        self.popup = self.popup.at_pointer();
74        self
75    }
76
77    /// Set the gap between the tooltip and the anchor
78    ///
79    /// Default: 5.0
80    #[inline]
81    pub fn gap(mut self, gap: f32) -> Self {
82        self.popup = self.popup.gap(gap);
83        self
84    }
85
86    /// Set the layout of the tooltip
87    #[inline]
88    pub fn layout(mut self, layout: Layout) -> Self {
89        self.popup = self.popup.layout(layout);
90        self
91    }
92
93    /// Set the width of the tooltip
94    #[inline]
95    pub fn width(mut self, width: f32) -> Self {
96        self.popup = self.popup.width(width);
97        self
98    }
99
100    /// Show the tooltip
101    pub fn show<R>(self, content: impl FnOnce(&mut crate::Ui) -> R) -> Option<InnerResponse<R>> {
102        let Self {
103            mut popup,
104            parent_layer,
105            parent_widget,
106        } = self;
107
108        if !popup.is_open() {
109            return None;
110        }
111
112        let rect = popup.get_anchor_rect()?;
113
114        let mut state = popup.ctx().pass_state_mut(|fs| {
115            // Remember that this is the widget showing the tooltip:
116            fs.layers
117                .entry(parent_layer)
118                .or_default()
119                .widget_with_tooltip = Some(parent_widget);
120
121            fs.tooltips
122                .widget_tooltips
123                .get(&parent_widget)
124                .copied()
125                .unwrap_or(PerWidgetTooltipState {
126                    bounding_rect: rect,
127                    tooltip_count: 0,
128                })
129        });
130
131        let tooltip_area_id = Self::tooltip_id(parent_widget, state.tooltip_count);
132
133        // Tooltips without interactive contents should not be interactable (hover should pass
134        // through to the widget below).
135        let interactable = Self::had_interactive_widgets(popup.ctx(), tooltip_area_id);
136
137        popup = popup
138            .anchor(state.bounding_rect)
139            .id(tooltip_area_id)
140            .interactable(interactable);
141
142        let response = popup.show(|ui| {
143            // By default, the text in tooltips aren't selectable.
144            // This means that most tooltips aren't interactable,
145            // which also mean they won't stick around so you can click them.
146            // Only tooltips that have actual interactive stuff (buttons, links, …)
147            // will stick around when you try to click them.
148            ui.style_mut().interaction.selectable_labels = false;
149
150            content(ui)
151        });
152
153        // The popup might not be shown on at_pointer if there is no pointer.
154        if let Some(response) = &response {
155            state.tooltip_count += 1;
156            state.bounding_rect |= response.response.rect;
157            response
158                .response
159                .ctx
160                .pass_state_mut(|fs| fs.tooltips.widget_tooltips.insert(parent_widget, state));
161            Self::remember_that_tooltip_was_shown(&response.response.ctx);
162        }
163
164        response
165    }
166
167    fn when_was_a_toolip_last_shown_id() -> Id {
168        Id::new("when_was_a_toolip_last_shown")
169    }
170
171    pub fn seconds_since_last_tooltip(ctx: &Context) -> f32 {
172        let when_was_a_toolip_last_shown =
173            ctx.data(|d| d.get_temp::<f64>(Self::when_was_a_toolip_last_shown_id()));
174
175        if let Some(when_was_a_toolip_last_shown) = when_was_a_toolip_last_shown {
176            let now = ctx.input(|i| i.time);
177            (now - when_was_a_toolip_last_shown) as f32
178        } else {
179            f32::INFINITY
180        }
181    }
182
183    fn remember_that_tooltip_was_shown(ctx: &Context) {
184        let now = ctx.input(|i| i.time);
185        ctx.data_mut(|data| data.insert_temp::<f64>(Self::when_was_a_toolip_last_shown_id(), now));
186    }
187
188    /// What is the id of the next tooltip for this widget?
189    pub fn next_tooltip_id(ctx: &Context, widget_id: Id) -> Id {
190        let tooltip_count = ctx.pass_state(|fs| {
191            fs.tooltips
192                .widget_tooltips
193                .get(&widget_id)
194                .map_or(0, |state| state.tooltip_count)
195        });
196        Self::tooltip_id(widget_id, tooltip_count)
197    }
198
199    pub fn tooltip_id(widget_id: Id, tooltip_count: usize) -> Id {
200        widget_id.with(tooltip_count)
201    }
202
203    /// Did this tooltip contain anything the user can interact with, last pass?
204    ///
205    /// Most tooltips are just text. Those should not react to the pointer at all,
206    /// or they would steal the hover from the widget they belong to.
207    fn had_interactive_widgets(ctx: &Context, tooltip_id: Id) -> bool {
208        let tooltip_layer_id = LayerId::new(Order::Tooltip, tooltip_id);
209        ctx.viewport(|vp| {
210            vp.prev_pass
211                .widgets
212                .get_layer(tooltip_layer_id)
213                .any(|w| w.enabled && w.sense.interactive())
214        })
215    }
216
217    /// Should we show a tooltip for this response?
218    ///
219    /// Argument `allow_interactive_tooltip` controls whether mouse can interact with tooltip that
220    /// contains interactive widgets
221    pub fn should_show_tooltip(response: &Response, allow_interactive_tooltip: bool) -> bool {
222        if response.ctx.memory(|mem| mem.everything_is_visible()) {
223            return true;
224        }
225
226        let any_open_popups = response.ctx.prev_pass_state(|fs| {
227            fs.layers
228                .get(&response.layer_id)
229                .is_some_and(|layer| !layer.open_popups.is_empty())
230        });
231        if any_open_popups {
232            // Hide tooltips if the user opens a popup (menu, combo-box, etc.) in the same layer.
233            return false;
234        }
235
236        let style = response.ctx.global_style();
237
238        let tooltip_delay = style.interaction.tooltip_delay;
239        let tooltip_grace_time = style.interaction.tooltip_grace_time;
240
241        let (
242            time_since_last_scroll,
243            time_since_last_click,
244            time_since_last_pointer_movement,
245            pointer_pos,
246            pointer_dir,
247        ) = response.ctx.input(|i| {
248            (
249                i.time_since_last_scroll(),
250                i.pointer.time_since_last_click(),
251                i.pointer.time_since_last_movement(),
252                i.pointer.hover_pos(),
253                i.pointer.direction(),
254            )
255        });
256
257        if time_since_last_scroll < tooltip_delay {
258            // See https://github.com/emilk/egui/issues/4781
259            // Note that this means we cannot have `ScrollArea`s in a tooltip.
260            response
261                .ctx
262                .request_repaint_after_secs(tooltip_delay - time_since_last_scroll);
263            return false;
264        }
265
266        let is_our_tooltip_open = response.is_tooltip_open();
267
268        if is_our_tooltip_open {
269            // Check if we should automatically stay open:
270
271            let tooltip_id = Self::next_tooltip_id(&response.ctx, response.id);
272
273            let tooltip_has_interactive_widget = allow_interactive_tooltip
274                && Self::had_interactive_widgets(&response.ctx, tooltip_id);
275
276            if tooltip_has_interactive_widget {
277                // We keep the tooltip open if hovered,
278                // or if the pointer is on its way to it,
279                // so that the user can interact with the tooltip
280                // (i.e. click links that are in it).
281                if let Some(area) = AreaState::load(&response.ctx, tooltip_id) {
282                    let rect = area.rect();
283
284                    if let Some(pos) = pointer_pos {
285                        if rect.contains(pos) {
286                            return true; // hovering interactive tooltip
287                        }
288                        if pointer_dir != Vec2::ZERO
289                            && rect.intersects_ray(pos, pointer_dir.normalized())
290                        {
291                            return true; // on the way to interactive tooltip
292                        }
293                    }
294                }
295            }
296        }
297
298        let clicked_more_recently_than_moved =
299            time_since_last_click < time_since_last_pointer_movement + 0.1;
300        if clicked_more_recently_than_moved {
301            // It is common to click a widget and then rest the mouse there.
302            // It would be annoying to then see a tooltip for it immediately.
303            // Similarly, clicking should hide the existing tooltip.
304            // Only hovering should lead to a tooltip, not clicking.
305            // The offset is only to allow small movement just right after the click.
306            return false;
307        }
308
309        if is_our_tooltip_open {
310            // Check if we should automatically stay open:
311
312            if pointer_pos.is_some_and(|pointer_pos| response.rect.contains(pointer_pos)) {
313                // Handle the case of a big tooltip that covers the widget:
314                return true;
315            }
316        }
317
318        let is_other_tooltip_open = response.ctx.prev_pass_state(|fs| {
319            if let Some(already_open_tooltip) = fs
320                .layers
321                .get(&response.layer_id)
322                .and_then(|layer| layer.widget_with_tooltip)
323            {
324                already_open_tooltip != response.id
325            } else {
326                false
327            }
328        });
329        if is_other_tooltip_open {
330            // We only allow one tooltip per layer. First one wins. It is up to that tooltip to close itself.
331            return false;
332        }
333
334        // Fast early-outs:
335        if response.enabled() {
336            if !response.hovered() || !response.ctx.input(|i| i.pointer.has_pointer()) {
337                return false;
338            }
339        } else if !response
340            .ctx
341            .rect_contains_pointer(response.layer_id, response.rect)
342        {
343            return false;
344        }
345
346        // There is a tooltip_delay before showing the first tooltip,
347        // but once one tooltip is show, moving the mouse cursor to
348        // another widget should show the tooltip for that widget right away.
349
350        // Let the user quickly move over some dead space to hover the next thing
351        let tooltip_was_recently_shown =
352            Self::seconds_since_last_tooltip(&response.ctx) < tooltip_grace_time;
353
354        if !tooltip_was_recently_shown && !is_our_tooltip_open {
355            if style.interaction.show_tooltips_only_when_still {
356                // We only show the tooltip when the mouse pointer is still.
357                if !response
358                    .ctx
359                    .input(|i| i.pointer.is_still() && !i.is_scrolling())
360                {
361                    // wait for mouse to stop
362                    response.ctx.request_repaint();
363                    return false;
364                }
365            }
366
367            let time_since_last_interaction = time_since_last_scroll
368                .min(time_since_last_pointer_movement)
369                .min(time_since_last_click);
370            let time_til_tooltip = tooltip_delay - time_since_last_interaction;
371
372            if 0.0 < time_til_tooltip {
373                // Wait until the mouse has been still for a while
374                response.ctx.request_repaint_after_secs(time_til_tooltip);
375                return false;
376            }
377        }
378
379        // We don't want tooltips of things while we are dragging them,
380        // but we do want tooltips while holding down on an item on a touch screen.
381        if response
382            .ctx
383            .input(|i| i.pointer.any_down() && i.pointer.has_moved_too_much_for_a_click)
384        {
385            return false;
386        }
387
388        // All checks passed: show the tooltip!
389
390        true
391    }
392
393    /// Was this tooltip visible last frame?
394    pub fn was_tooltip_open_last_frame(ctx: &Context, widget_id: Id) -> bool {
395        let primary_tooltip_area_id = Self::tooltip_id(widget_id, 0);
396        ctx.memory(|mem| {
397            mem.areas()
398                .visible_last_frame(&LayerId::new(Order::Tooltip, primary_tooltip_area_id))
399        })
400    }
401}