Skip to main content

agg_gui/widgets/tooltip/
mod.rs

1//! `Tooltip` — a wrapper widget that shows egui-style hover help.
2//!
3//! Tooltips come in two flavours:
4//!
5//! * **Lightweight** (the default, used by dozens of call sites): text lines
6//!   submitted during the widget paint pass and drawn at the end of the frame
7//!   by [`crate::widget::App`] via a global queue ([`render`]). Fire-and-forget,
8//!   no hit-testing.
9//!
10//! * **Interactive** (opt-in via [`Tooltip::with_interactive_content`]): the tip
11//!   is a real floating UI surface hosting a child widget tree (labels,
12//!   hyperlinks, a nested tooltip). It participates in the global-overlay
13//!   hit-test so the pointer can move *into* it without dismissing it, and it
14//!   supports one level of nesting — see [`interactive`]. This mirrors egui's
15//!   `on_hover_ui` tooltips.
16//!
17//! # Usage
18//!
19//! ```ignore
20//! Tooltip::new(
21//!     Box::new(Button::new("Hover me", font.clone()).on_click(|| {})),
22//!     "This is a tooltip",
23//!     font.clone(),
24//! )
25//! ```
26
27pub mod controller;
28mod interactive;
29mod render;
30mod timings;
31
32pub(crate) use render::{begin_tooltip_frame, paint_global_tooltips};
33pub use timings::{set_tooltip_timings, tooltip_timings, TooltipTimings};
34#[doc(hidden)]
35pub use timings::{
36    advance_tooltip_test_clock, reset_tooltip_test_state, set_tooltip_test_clock,
37};
38
39use timings::{last_tooltip_visible_at, note_tooltip_visible, tooltip_now};
40
41use std::rc::Rc;
42use std::sync::Arc;
43use std::time::Duration;
44use web_time::Instant;
45
46use crate::draw_ctx::DrawCtx;
47use crate::event::{Event, EventResult};
48use crate::geometry::{Point, Rect, Size};
49use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
50use crate::text::Font;
51use crate::widget::{current_mouse_world, Widget};
52
53use render::{submit_tooltip, TooltipRequest};
54
55/// Compile-time default initial hover delay, kept as a stable reference for
56/// tests and for interactive-tip code paths. Runtime code reads the (possibly
57/// OS-overridden) value via [`tooltip_timings`]; this equals the default there.
58const TOOLTIP_INITIAL_DELAY: Duration = timings::DEFAULT_INITIAL_DELAY;
59pub(super) const TOOLTIP_FONT_SIZE: f64 = 12.0;
60pub(super) const TOOLTIP_PAD_X: f64 = 8.0;
61pub(super) const TOOLTIP_PAD_Y: f64 = 6.0;
62pub(super) const TOOLTIP_GAP: f64 = 4.0;
63/// Extra vertical offset for pointer-anchored tooltips.  They should
64/// read as attached below the cursor rather than hugging it.
65pub(super) const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
66pub(super) const SCREEN_MARGIN: f64 = 4.0;
67
68#[derive(Clone)]
69pub(super) enum TooltipLineKind {
70    Text,
71    Code,
72    Link,
73}
74
75#[derive(Clone)]
76pub(super) struct TooltipLine {
77    pub text: String,
78    pub kind: TooltipLineKind,
79}
80
81/// A wrapper widget that shows a text tooltip on hover.
82pub struct Tooltip {
83    bounds: Rect,
84    /// The wrapped child widget is stored in `children[0]`.
85    children: Vec<Box<dyn Widget>>,
86    base: WidgetBase,
87
88    /// Time when the pointer entered the widget.  `None` when the
89    /// pointer is outside. Wall-clock timing gives consistent tooltip
90    /// latency even when the app is not repainting continuously.
91    hover_started_at: Option<Instant>,
92    /// Whether the cursor is currently inside the widget bounds.
93    hovered: bool,
94    /// Whether this tooltip was visible on the previous paint. Used
95    /// to invalidate when the delayed tooltip appears or disappears,
96    /// not just when hover state changes.
97    tooltip_visible: bool,
98    /// Delay to apply for the current hover, captured when the pointer entered:
99    /// the full initial delay, or the shorter reshow delay if another tip was
100    /// visible within the reshow window (move-along-the-toolbar).
101    pending_delay: Duration,
102    /// When the current tip became visible, used to auto-dismiss after
103    /// [`TooltipTimings::autopop`].
104    tip_shown_at: Option<Instant>,
105    /// Suppresses (re)showing until the pointer leaves and re-enters. Set on any
106    /// mouse press over the child and on autopop, mirroring Windows: a click or a
107    /// timed-out tip stays hidden until you move off and back onto the control.
108    suppressed: bool,
109    /// Whether a mouse button is currently held; tips never show while pressed.
110    pointer_down: bool,
111    /// Last known cursor position in local coordinates.
112    cursor: Point,
113
114    font: Arc<Font>,
115    lines: Vec<TooltipLine>,
116    disabled_lines: Vec<TooltipLine>,
117    disabled_when: Option<Rc<dyn Fn() -> bool>>,
118    at_pointer: bool,
119
120    // --- Interactive-mode state (see `interactive`) ---------------------
121    /// When `true`, the tip is a hit-testable surface hosting `content`
122    /// instead of the lightweight text queue.
123    interactive: bool,
124    /// The interactive tip's child widget tree. Not part of `children`, so
125    /// it is not laid out / painted / hit-tested by the normal tree walk —
126    /// [`interactive`] manages it manually at the floating tip position.
127    content: Option<Box<dyn Widget>>,
128    /// Natural size of `content` from its last layout.
129    content_size: Size,
130    /// Latched open-state for the interactive tip. Stays open while the
131    /// pointer is over the anchor OR the tip; closes after a grace period
132    /// once it leaves both (or on Escape).
133    tip_open: bool,
134    /// Whether the pointer is currently over the interactive tip surface.
135    tip_hovered: bool,
136    /// Panel rectangle in this widget's LOCAL coordinate space, captured
137    /// during the last overlay paint and reused for hit-testing.
138    tip_panel_local: Option<Rect>,
139    /// Where `content` is painted within the panel (local coords).
140    content_origin_local: Point,
141    /// When the pointer left both anchor and tip; the tip closes once this
142    /// exceeds [`interactive::TOOLTIP_CLOSE_GRACE`].
143    close_requested_at: Option<Instant>,
144    /// Path of the content descendant the pointer last hovered, so we can
145    /// clear its hover (and any nested tooltip) when the pointer moves off.
146    last_content_path: Option<Vec<usize>>,
147}
148
149impl Tooltip {
150    /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
151    pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
152        Self {
153            bounds: Rect::default(),
154            children: vec![child],
155            base: WidgetBase::new(),
156            hover_started_at: None,
157            hovered: false,
158            tooltip_visible: false,
159            pending_delay: TOOLTIP_INITIAL_DELAY,
160            tip_shown_at: None,
161            suppressed: false,
162            pointer_down: false,
163            cursor: Point::ORIGIN,
164            font,
165            lines: text_to_lines(text),
166            disabled_lines: Vec::new(),
167            disabled_when: None,
168            at_pointer: true,
169            interactive: false,
170            content: None,
171            content_size: Size::new(0.0, 0.0),
172            tip_open: false,
173            tip_hovered: false,
174            tip_panel_local: None,
175            content_origin_local: Point::ORIGIN,
176            close_requested_at: None,
177            last_content_path: None,
178        }
179    }
180
181    /// Add another hover text block, matching egui's ability to chain
182    /// `.on_hover_text(...)` calls.
183    pub fn with_text(mut self, text: impl Into<String>) -> Self {
184        self.lines.extend(text_to_lines(text));
185        self
186    }
187
188    /// Add a code-styled line to the tooltip.
189    pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
190        self.lines.push(TooltipLine {
191            text: text.into(),
192            kind: TooltipLineKind::Code,
193        });
194        self
195    }
196
197    /// Add a link-styled line to the tooltip.  Tooltip overlays are
198    /// informational; the line is styled like a link but does not receive
199    /// pointer events.
200    pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
201        self.lines.push(TooltipLine {
202            text: text.into(),
203            kind: TooltipLineKind::Link,
204        });
205        self
206    }
207
208    /// Turn this tooltip into an **interactive** surface hosting `content`
209    /// (a small widget tree of labels / hyperlinks). The lightweight text
210    /// lines are ignored while interactive. The pointer can enter the tip
211    /// without dismissing it, and a tooltip-bearing widget inside `content`
212    /// shows its own (nested) tip. Mirrors egui's `on_hover_ui`.
213    pub fn with_interactive_content(mut self, content: Box<dyn Widget>) -> Self {
214        self.interactive = true;
215        self.content = Some(content);
216        self.at_pointer = false;
217        self
218    }
219
220    /// Place the tooltip relative to the mouse cursor instead of the widget.
221    /// This is the default; kept for call-site clarity.
222    pub fn at_pointer(mut self) -> Self {
223        self.at_pointer = true;
224        self
225    }
226
227    /// Place the tooltip relative to the wrapped widget instead of the
228    /// mouse cursor.
229    pub fn at_widget(mut self) -> Self {
230        self.at_pointer = false;
231        self
232    }
233
234    /// Use alternate tooltip text while `disabled_when` returns true.
235    pub fn with_disabled_text(
236        mut self,
237        text: impl Into<String>,
238        disabled_when: impl Fn() -> bool + 'static,
239    ) -> Self {
240        self.disabled_lines = text_to_lines(text);
241        self.disabled_when = Some(Rc::new(disabled_when));
242        self
243    }
244
245    pub fn with_margin(mut self, m: Insets) -> Self {
246        self.base.margin = m;
247        self
248    }
249    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
250        self.base.h_anchor = h;
251        self
252    }
253    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
254        self.base.v_anchor = v;
255        self
256    }
257
258    /// Delay to apply for a hover starting now: the shorter reshow delay when a
259    /// tip was visible within the reshow window, otherwise the full initial
260    /// delay. Captured at pointer-enter into [`Self::pending_delay`].
261    fn effective_delay(&self) -> Duration {
262        let t = tooltip_timings();
263        match last_tooltip_visible_at() {
264            Some(at) if tooltip_now().saturating_duration_since(at) <= t.reshow_window() => {
265                t.reshow_delay
266            }
267            _ => t.initial_delay,
268        }
269    }
270
271    /// Whether the tip should be visible right now, ignoring autopop (handled
272    /// separately in [`Self::update_visibility`]).
273    fn should_show_tip(&self) -> bool {
274        if self.suppressed || self.pointer_down || !self.hovered {
275            return false;
276        }
277        match self.hover_started_at {
278            Some(started) => {
279                tooltip_now().saturating_duration_since(started) >= self.pending_delay
280            }
281            None => false,
282        }
283    }
284
285    /// Advance the lightweight tip's visibility state machine. Applies autopop,
286    /// flips [`Self::tooltip_visible`], warms the shared reshow window, and
287    /// schedules the wall-clock wake that drives the delayed show / auto-dismiss
288    /// without continuous repainting. Returns whether the tip is now visible.
289    ///
290    /// Split out from `paint_overlay` so the state machine is unit-testable
291    /// without a `DrawCtx`.
292    fn update_visibility(&mut self) -> bool {
293        // Auto-dismiss a tip that has sat open too long, and keep it hidden
294        // until the pointer leaves and re-enters (Windows AUTOPOP behaviour).
295        if self.tooltip_visible {
296            if let Some(shown) = self.tip_shown_at {
297                let autopop = tooltip_timings().autopop;
298                let elapsed = tooltip_now().saturating_duration_since(shown);
299                if elapsed >= autopop {
300                    self.hide_tip();
301                    self.suppressed = true;
302                    return false;
303                }
304                // Re-arm the autopop wake every visible frame: the deadline is
305                // read-and-cleared by the host each frame, so an intervening
306                // repaint would otherwise drop it and the dismiss never fires.
307                crate::animation::request_draw_after_tagged(
308                    autopop - elapsed,
309                    "tooltip.wrapper.autopop_rearm",
310                );
311            }
312        }
313
314        if self.should_show_tip() {
315            if !self.tooltip_visible {
316                self.tooltip_visible = true;
317                self.tip_shown_at = Some(tooltip_now());
318                crate::animation::request_draw_tagged("tooltip.wrapper.show");
319                // Wake at the autopop deadline so a still pointer still dismisses.
320                crate::animation::request_draw_after_tagged(
321                    tooltip_timings().autopop,
322                    "tooltip.wrapper.autopop_arm",
323                );
324            }
325            note_tooltip_visible();
326            return true;
327        }
328
329        if self.tooltip_visible {
330            self.hide_tip();
331        } else if let Some(remaining) = self.remaining_delay() {
332            // Not yet time to show: schedule the wake at the show deadline.
333            if remaining.is_zero() {
334                crate::animation::request_draw_tagged("tooltip.wrapper.remaining_zero");
335            } else {
336                crate::animation::request_draw_after_tagged(
337                    remaining,
338                    "tooltip.wrapper.remaining",
339                );
340            }
341        }
342        false
343    }
344
345    /// Time left before the pending show fires, or `None` when no show is
346    /// pending (not hovered, suppressed, or a button is held).
347    fn remaining_delay(&self) -> Option<Duration> {
348        if !self.hovered || self.suppressed || self.pointer_down {
349            return None;
350        }
351        let elapsed = tooltip_now().saturating_duration_since(self.hover_started_at?);
352        Some(self.pending_delay.saturating_sub(elapsed))
353    }
354
355    /// Hide the tip and clear its display timer, invalidating so the overlay
356    /// clears on the next paint.
357    fn hide_tip(&mut self) {
358        if self.tooltip_visible {
359            crate::animation::request_draw();
360        }
361        self.tooltip_visible = false;
362        self.tip_shown_at = None;
363    }
364
365    fn active_lines(&self) -> Vec<TooltipLine> {
366        if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
367            && !self.disabled_lines.is_empty()
368        {
369            self.disabled_lines.clone()
370        } else {
371            self.lines.clone()
372        }
373    }
374}
375
376impl Widget for Tooltip {
377    fn type_name(&self) -> &'static str {
378        "Tooltip"
379    }
380    fn bounds(&self) -> Rect {
381        self.bounds
382    }
383    fn set_bounds(&mut self, b: Rect) {
384        self.bounds = b;
385    }
386    fn children(&self) -> &[Box<dyn Widget>] {
387        &self.children
388    }
389    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
390        &mut self.children
391    }
392
393    fn margin(&self) -> Insets {
394        self.base.margin
395    }
396    fn widget_base(&self) -> Option<&WidgetBase> {
397        Some(&self.base)
398    }
399    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
400        Some(&mut self.base)
401    }
402    fn h_anchor(&self) -> HAnchor {
403        self.base.h_anchor
404    }
405    fn v_anchor(&self) -> VAnchor {
406        self.base.v_anchor
407    }
408
409    /// Forward the wrapped child's size constraints so the tooltip is a
410    /// transparent layout wrapper. Without this a constrained child — e.g. a
411    /// [`ComboBox`](crate::widgets::combo_box::ComboBox) whose `max_size` caps its
412    /// width — would lose that cap when wrapped and placed in a `FlexRow`, because
413    /// the row reads the *wrapper's* constraints, not the child's, and would let
414    /// the combo stretch across the whole row.
415    fn min_size(&self) -> Size {
416        self.children
417            .first()
418            .map(|c| c.min_size())
419            .unwrap_or(Size::ZERO)
420    }
421    fn max_size(&self) -> Size {
422        self.children
423            .first()
424            .map(|c| c.max_size())
425            .unwrap_or(Size::MAX)
426    }
427
428    /// Expose the tip text to the inspector and to tests, so a tooltip's presence
429    /// and message can be asserted by walking the widget tree (there is no public
430    /// downcast for `dyn Widget`).
431    fn properties(&self) -> Vec<(&'static str, String)> {
432        vec![(
433            "tooltip",
434            self.lines
435                .iter()
436                .map(|l| l.text.as_str())
437                .collect::<Vec<_>>()
438                .join("\n"),
439        )]
440    }
441
442    fn is_focusable(&self) -> bool {
443        self.children
444            .first()
445            .map(|c| c.is_focusable())
446            .unwrap_or(false)
447    }
448
449    fn layout(&mut self, available: Size) -> Size {
450        let s = if let Some(child) = self.children.first_mut() {
451            let cs = child.layout(available);
452            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
453            cs
454        } else {
455            available
456        };
457        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
458        s
459    }
460
461    fn paint(&mut self, _: &mut dyn DrawCtx) {}
462
463    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
464        // Interactive tips paint their surface in `paint_global_overlay`,
465        // not through the lightweight text queue.
466        if self.interactive {
467            return;
468        }
469
470        // Advance the delay/reshow/autopop state machine. It handles its own
471        // invalidation and schedules the wall-clock wake for the delayed show
472        // or auto-dismiss, so no busy-repaint loop is needed.
473        if !self.update_visibility() {
474            return;
475        }
476
477        let anchor = if self.at_pointer {
478            current_mouse_world().unwrap_or(self.cursor)
479        } else {
480            let mut x = self.bounds.width * 0.5;
481            // Widget-anchored tooltips should appear below the
482            // hovered widget by default (MatterCAD-style). In
483            // agg-gui's Y-up coords, the bottom edge is y=0; the
484            // global paint step will offset the panel by
485            // `TOOLTIP_GAP` from this anchor.
486            let mut y = 0.0;
487            ctx.root_transform().transform(&mut x, &mut y);
488            Point::new(x, y)
489        };
490        submit_tooltip(TooltipRequest {
491            font: Arc::clone(&self.font),
492            lines: self.active_lines(),
493            anchor,
494            at_pointer: self.at_pointer,
495        });
496    }
497
498    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
499        if self.interactive {
500            self.paint_interactive_tip(ctx);
501        }
502    }
503
504    fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
505        self.interactive && self.interactive_hit(local_pos)
506    }
507
508    fn on_unconsumed_key(
509        &mut self,
510        key: &crate::event::Key,
511        _modifiers: crate::event::Modifiers,
512    ) -> EventResult {
513        if self.interactive {
514            self.interactive_unconsumed_key(key)
515        } else {
516            EventResult::Ignored
517        }
518    }
519
520    fn on_event(&mut self, event: &Event) -> EventResult {
521        if self.interactive {
522            return self.on_interactive_event(event);
523        }
524        match event {
525            Event::MouseMove { pos } => {
526                let was = self.hovered;
527                self.hovered = self.hit_test(*pos);
528                self.cursor = *pos;
529                if self.hovered && !was {
530                    // Entering: start the hover timer, picking the reshow delay
531                    // when the subsystem is still warm from a recent tip.
532                    self.hover_started_at = Some(tooltip_now());
533                    self.pending_delay = self.effective_delay();
534                    self.suppressed = false;
535                    crate::animation::request_draw_after_tagged(
536                        self.pending_delay,
537                        "tooltip.wrapper.enter_arm",
538                    );
539                } else if !self.hovered && was {
540                    // Leaving: reset so the next entry re-arms cleanly and any
541                    // press/autopop suppression clears.
542                    self.hover_started_at = None;
543                    self.suppressed = false;
544                    self.hide_tip();
545                }
546                if self.hovered != was {
547                    crate::animation::request_draw();
548                }
549                self.children
550                    .first_mut()
551                    .map(|child| child.on_event(event))
552                    .unwrap_or(EventResult::Ignored)
553            }
554            Event::MouseDown { .. } => {
555                // A press over the control hides the tip and keeps it hidden
556                // until the pointer leaves and re-enters.
557                self.pointer_down = true;
558                self.suppressed = true;
559                self.hide_tip();
560                self.children
561                    .first_mut()
562                    .map(|child| child.on_event(event))
563                    .unwrap_or(EventResult::Ignored)
564            }
565            Event::MouseUp { .. } => {
566                self.pointer_down = false;
567                self.children
568                    .first_mut()
569                    .map(|child| child.on_event(event))
570                    .unwrap_or(EventResult::Ignored)
571            }
572            Event::MouseWheel { .. } => {
573                self.hovered = false;
574                self.hover_started_at = None;
575                self.suppressed = false;
576                self.hide_tip();
577                self.children
578                    .first_mut()
579                    .map(|child| child.on_event(event))
580                    .unwrap_or(EventResult::Ignored)
581            }
582            _ => self
583                .children
584                .first_mut()
585                .map(|child| child.on_event(event))
586                .unwrap_or(EventResult::Ignored),
587        }
588    }
589
590    fn hit_test(&self, local_pos: Point) -> bool {
591        local_pos.x >= 0.0
592            && local_pos.x <= self.bounds.width
593            && local_pos.y >= 0.0
594            && local_pos.y <= self.bounds.height
595    }
596}
597
598fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
599    text.into()
600        .lines()
601        .map(|line| TooltipLine {
602            text: line.to_owned(),
603            kind: TooltipLineKind::Text,
604        })
605        .collect()
606}
607
608#[cfg(test)]
609mod tests;