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
27mod interactive;
28mod render;
29
30pub(crate) use render::{begin_tooltip_frame, paint_global_tooltips};
31
32use std::rc::Rc;
33use std::sync::Arc;
34use std::time::Duration;
35use web_time::Instant;
36
37use crate::draw_ctx::DrawCtx;
38use crate::event::{Event, EventResult};
39use crate::geometry::{Point, Rect, Size};
40use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
41use crate::text::Font;
42use crate::widget::{current_mouse_world, Widget};
43
44use render::{submit_tooltip, TooltipRequest};
45
46/// Standard initial hover delay before the tooltip appears.
47///
48/// Windows common controls default to roughly 500ms. MatterCAD uses
49/// 0.6s. Use 500ms and make it wall-clock based so the delay is not
50/// dependent on redraw frequency.
51const TOOLTIP_INITIAL_DELAY: Duration = Duration::from_millis(500);
52pub(super) const TOOLTIP_FONT_SIZE: f64 = 12.0;
53pub(super) const TOOLTIP_PAD_X: f64 = 8.0;
54pub(super) const TOOLTIP_PAD_Y: f64 = 6.0;
55pub(super) const TOOLTIP_GAP: f64 = 4.0;
56/// Extra vertical offset for pointer-anchored tooltips.  They should
57/// read as attached below the cursor rather than hugging it.
58pub(super) const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
59pub(super) const SCREEN_MARGIN: f64 = 4.0;
60
61#[derive(Clone)]
62pub(super) enum TooltipLineKind {
63    Text,
64    Code,
65    Link,
66}
67
68#[derive(Clone)]
69pub(super) struct TooltipLine {
70    pub text: String,
71    pub kind: TooltipLineKind,
72}
73
74/// A wrapper widget that shows a text tooltip on hover.
75pub struct Tooltip {
76    bounds: Rect,
77    /// The wrapped child widget is stored in `children[0]`.
78    children: Vec<Box<dyn Widget>>,
79    base: WidgetBase,
80
81    /// Time when the pointer entered the widget.  `None` when the
82    /// pointer is outside. Wall-clock timing gives consistent tooltip
83    /// latency even when the app is not repainting continuously.
84    hover_started_at: Option<Instant>,
85    /// Whether the cursor is currently inside the widget bounds.
86    hovered: bool,
87    /// Whether this tooltip was visible on the previous paint. Used
88    /// to invalidate when the delayed tooltip appears or disappears,
89    /// not just when hover state changes.
90    tooltip_visible: bool,
91    /// Last known cursor position in local coordinates.
92    cursor: Point,
93
94    font: Arc<Font>,
95    lines: Vec<TooltipLine>,
96    disabled_lines: Vec<TooltipLine>,
97    disabled_when: Option<Rc<dyn Fn() -> bool>>,
98    at_pointer: bool,
99
100    // --- Interactive-mode state (see `interactive`) ---------------------
101    /// When `true`, the tip is a hit-testable surface hosting `content`
102    /// instead of the lightweight text queue.
103    interactive: bool,
104    /// The interactive tip's child widget tree. Not part of `children`, so
105    /// it is not laid out / painted / hit-tested by the normal tree walk —
106    /// [`interactive`] manages it manually at the floating tip position.
107    content: Option<Box<dyn Widget>>,
108    /// Natural size of `content` from its last layout.
109    content_size: Size,
110    /// Latched open-state for the interactive tip. Stays open while the
111    /// pointer is over the anchor OR the tip; closes after a grace period
112    /// once it leaves both (or on Escape).
113    tip_open: bool,
114    /// Whether the pointer is currently over the interactive tip surface.
115    tip_hovered: bool,
116    /// Panel rectangle in this widget's LOCAL coordinate space, captured
117    /// during the last overlay paint and reused for hit-testing.
118    tip_panel_local: Option<Rect>,
119    /// Where `content` is painted within the panel (local coords).
120    content_origin_local: Point,
121    /// When the pointer left both anchor and tip; the tip closes once this
122    /// exceeds [`interactive::TOOLTIP_CLOSE_GRACE`].
123    close_requested_at: Option<Instant>,
124    /// Path of the content descendant the pointer last hovered, so we can
125    /// clear its hover (and any nested tooltip) when the pointer moves off.
126    last_content_path: Option<Vec<usize>>,
127}
128
129impl Tooltip {
130    /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
131    pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
132        Self {
133            bounds: Rect::default(),
134            children: vec![child],
135            base: WidgetBase::new(),
136            hover_started_at: None,
137            hovered: false,
138            tooltip_visible: false,
139            cursor: Point::ORIGIN,
140            font,
141            lines: text_to_lines(text),
142            disabled_lines: Vec::new(),
143            disabled_when: None,
144            at_pointer: true,
145            interactive: false,
146            content: None,
147            content_size: Size::new(0.0, 0.0),
148            tip_open: false,
149            tip_hovered: false,
150            tip_panel_local: None,
151            content_origin_local: Point::ORIGIN,
152            close_requested_at: None,
153            last_content_path: None,
154        }
155    }
156
157    /// Add another hover text block, matching egui's ability to chain
158    /// `.on_hover_text(...)` calls.
159    pub fn with_text(mut self, text: impl Into<String>) -> Self {
160        self.lines.extend(text_to_lines(text));
161        self
162    }
163
164    /// Add a code-styled line to the tooltip.
165    pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
166        self.lines.push(TooltipLine {
167            text: text.into(),
168            kind: TooltipLineKind::Code,
169        });
170        self
171    }
172
173    /// Add a link-styled line to the tooltip.  Tooltip overlays are
174    /// informational; the line is styled like a link but does not receive
175    /// pointer events.
176    pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
177        self.lines.push(TooltipLine {
178            text: text.into(),
179            kind: TooltipLineKind::Link,
180        });
181        self
182    }
183
184    /// Turn this tooltip into an **interactive** surface hosting `content`
185    /// (a small widget tree of labels / hyperlinks). The lightweight text
186    /// lines are ignored while interactive. The pointer can enter the tip
187    /// without dismissing it, and a tooltip-bearing widget inside `content`
188    /// shows its own (nested) tip. Mirrors egui's `on_hover_ui`.
189    pub fn with_interactive_content(mut self, content: Box<dyn Widget>) -> Self {
190        self.interactive = true;
191        self.content = Some(content);
192        self.at_pointer = false;
193        self
194    }
195
196    /// Place the tooltip relative to the mouse cursor instead of the widget.
197    /// This is the default; kept for call-site clarity.
198    pub fn at_pointer(mut self) -> Self {
199        self.at_pointer = true;
200        self
201    }
202
203    /// Place the tooltip relative to the wrapped widget instead of the
204    /// mouse cursor.
205    pub fn at_widget(mut self) -> Self {
206        self.at_pointer = false;
207        self
208    }
209
210    /// Use alternate tooltip text while `disabled_when` returns true.
211    pub fn with_disabled_text(
212        mut self,
213        text: impl Into<String>,
214        disabled_when: impl Fn() -> bool + 'static,
215    ) -> Self {
216        self.disabled_lines = text_to_lines(text);
217        self.disabled_when = Some(Rc::new(disabled_when));
218        self
219    }
220
221    pub fn with_margin(mut self, m: Insets) -> Self {
222        self.base.margin = m;
223        self
224    }
225    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
226        self.base.h_anchor = h;
227        self
228    }
229    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
230        self.base.v_anchor = v;
231        self
232    }
233
234    fn show_tip(&self) -> bool {
235        self.hovered
236            && self
237                .hover_started_at
238                .map(|started| started.elapsed() >= TOOLTIP_INITIAL_DELAY)
239                .unwrap_or(false)
240    }
241
242    fn remaining_delay(&self) -> Option<Duration> {
243        if !self.hovered {
244            return None;
245        }
246        let elapsed = self.hover_started_at?.elapsed();
247        Some(TOOLTIP_INITIAL_DELAY.saturating_sub(elapsed))
248    }
249
250    fn active_lines(&self) -> Vec<TooltipLine> {
251        if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
252            && !self.disabled_lines.is_empty()
253        {
254            self.disabled_lines.clone()
255        } else {
256            self.lines.clone()
257        }
258    }
259}
260
261impl Widget for Tooltip {
262    fn type_name(&self) -> &'static str {
263        "Tooltip"
264    }
265    fn bounds(&self) -> Rect {
266        self.bounds
267    }
268    fn set_bounds(&mut self, b: Rect) {
269        self.bounds = b;
270    }
271    fn children(&self) -> &[Box<dyn Widget>] {
272        &self.children
273    }
274    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
275        &mut self.children
276    }
277
278    fn margin(&self) -> Insets {
279        self.base.margin
280    }
281    fn widget_base(&self) -> Option<&WidgetBase> {
282        Some(&self.base)
283    }
284    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
285        Some(&mut self.base)
286    }
287    fn h_anchor(&self) -> HAnchor {
288        self.base.h_anchor
289    }
290    fn v_anchor(&self) -> VAnchor {
291        self.base.v_anchor
292    }
293
294    fn is_focusable(&self) -> bool {
295        self.children
296            .first()
297            .map(|c| c.is_focusable())
298            .unwrap_or(false)
299    }
300
301    fn layout(&mut self, available: Size) -> Size {
302        let s = if let Some(child) = self.children.first_mut() {
303            let cs = child.layout(available);
304            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
305            cs
306        } else {
307            available
308        };
309        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
310        s
311    }
312
313    fn paint(&mut self, _: &mut dyn DrawCtx) {}
314
315    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
316        // Interactive tips paint their surface in `paint_global_overlay`,
317        // not through the lightweight text queue.
318        if self.interactive {
319            return;
320        }
321
322        let should_show = self.show_tip();
323
324        if self.hovered && !should_show {
325            if let Some(remaining) = self.remaining_delay() {
326                if remaining.is_zero() {
327                    crate::animation::request_draw();
328                } else {
329                    crate::animation::request_draw_after(remaining);
330                }
331            }
332        }
333
334        if should_show != self.tooltip_visible {
335            self.tooltip_visible = should_show;
336            // The visible tooltip is a global overlay, but the request
337            // is produced by this widget during paint.  Bump the normal
338            // invalidation path so retained ancestors and the global
339            // tooltip queue redraw when the delayed tooltip appears or
340            // disappears.
341            crate::animation::request_draw();
342        }
343
344        if !should_show {
345            return;
346        }
347
348        let anchor = if self.at_pointer {
349            current_mouse_world().unwrap_or(self.cursor)
350        } else {
351            let mut x = self.bounds.width * 0.5;
352            // Widget-anchored tooltips should appear below the
353            // hovered widget by default (MatterCAD-style). In
354            // agg-gui's Y-up coords, the bottom edge is y=0; the
355            // global paint step will offset the panel by
356            // `TOOLTIP_GAP` from this anchor.
357            let mut y = 0.0;
358            ctx.root_transform().transform(&mut x, &mut y);
359            Point::new(x, y)
360        };
361        submit_tooltip(TooltipRequest {
362            font: Arc::clone(&self.font),
363            lines: self.active_lines(),
364            anchor,
365            at_pointer: self.at_pointer,
366        });
367    }
368
369    fn paint_global_overlay(&mut self, ctx: &mut dyn DrawCtx) {
370        if self.interactive {
371            self.paint_interactive_tip(ctx);
372        }
373    }
374
375    fn hit_test_global_overlay(&self, local_pos: Point) -> bool {
376        self.interactive && self.interactive_hit(local_pos)
377    }
378
379    fn on_unconsumed_key(
380        &mut self,
381        key: &crate::event::Key,
382        _modifiers: crate::event::Modifiers,
383    ) -> EventResult {
384        if self.interactive {
385            self.interactive_unconsumed_key(key)
386        } else {
387            EventResult::Ignored
388        }
389    }
390
391    fn on_event(&mut self, event: &Event) -> EventResult {
392        if self.interactive {
393            return self.on_interactive_event(event);
394        }
395        match event {
396            Event::MouseMove { pos } => {
397                let was = self.hovered;
398                self.hovered = self.hit_test(*pos);
399                self.cursor = *pos;
400                if self.hovered && !was {
401                    self.hover_started_at = Some(Instant::now());
402                    crate::animation::request_draw_after(TOOLTIP_INITIAL_DELAY);
403                } else if !self.hovered {
404                    self.hover_started_at = None;
405                    if self.tooltip_visible {
406                        self.tooltip_visible = false;
407                        crate::animation::request_draw();
408                    }
409                }
410                if self.hovered != was {
411                    crate::animation::request_draw();
412                }
413                self.children
414                    .first_mut()
415                    .map(|child| child.on_event(event))
416                    .unwrap_or(EventResult::Ignored)
417            }
418            Event::MouseWheel { .. } => {
419                self.hovered = false;
420                self.hover_started_at = None;
421                if self.tooltip_visible {
422                    self.tooltip_visible = false;
423                    crate::animation::request_draw();
424                }
425                self.children
426                    .first_mut()
427                    .map(|child| child.on_event(event))
428                    .unwrap_or(EventResult::Ignored)
429            }
430            _ => self
431                .children
432                .first_mut()
433                .map(|child| child.on_event(event))
434                .unwrap_or(EventResult::Ignored),
435        }
436    }
437
438    fn hit_test(&self, local_pos: Point) -> bool {
439        local_pos.x >= 0.0
440            && local_pos.x <= self.bounds.width
441            && local_pos.y >= 0.0
442            && local_pos.y <= self.bounds.height
443    }
444}
445
446fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
447    text.into()
448        .lines()
449        .map(|line| TooltipLine {
450            text: line.to_owned(),
451            kind: TooltipLineKind::Text,
452        })
453        .collect()
454}
455
456#[cfg(test)]
457mod tests;