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