Skip to main content

agg_gui/widgets/
tooltip.rs

1//! `Tooltip` — a wrapper widget that shows egui-style hover help.
2//!
3//! Tooltips are submitted during the normal widget paint pass, but drawn at the
4//! end of the frame by [`crate::widget::App`].  That makes them true floating
5//! overlays instead of child-local decorations, so they can escape scroll-area
6//! clips and window content clips.
7//!
8//! # Usage
9//!
10//! ```ignore
11//! Tooltip::new(
12//!     Box::new(Button::new("Hover me", font.clone()).on_click(|| {})),
13//!     "This is a tooltip",
14//!     font.clone(),
15//! )
16//! ```
17
18use std::cell::RefCell;
19use std::rc::Rc;
20use std::sync::Arc;
21use std::time::Duration;
22use web_time::Instant;
23
24use crate::color::Color;
25use crate::draw_ctx::DrawCtx;
26use crate::event::{Event, EventResult};
27use crate::geometry::{Point, Rect, Size};
28use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
29use crate::text::Font;
30use crate::widget::{current_mouse_world, Widget};
31
32/// Standard initial hover delay before the tooltip appears.
33///
34/// Windows common controls default to roughly 500ms. MatterCAD uses
35/// 0.6s. Use 500ms and make it wall-clock based so the delay is not
36/// dependent on redraw frequency.
37const TOOLTIP_INITIAL_DELAY: Duration = Duration::from_millis(500);
38const TOOLTIP_FONT_SIZE: f64 = 12.0;
39const TOOLTIP_PAD_X: f64 = 8.0;
40const TOOLTIP_PAD_Y: f64 = 6.0;
41const TOOLTIP_GAP: f64 = 4.0;
42/// Extra vertical offset for pointer-anchored tooltips.  They should
43/// read as attached below the cursor rather than hugging it.
44const POINTER_TOOLTIP_EXTRA_DROP: f64 = 10.0;
45const SCREEN_MARGIN: f64 = 4.0;
46
47#[derive(Clone)]
48enum TooltipLineKind {
49    Text,
50    Code,
51    Link,
52}
53
54#[derive(Clone)]
55struct TooltipLine {
56    text: String,
57    kind: TooltipLineKind,
58}
59
60struct TooltipRequest {
61    font: Arc<Font>,
62    lines: Vec<TooltipLine>,
63    anchor: Point,
64    at_pointer: bool,
65}
66
67thread_local! {
68    static TOOLTIP_QUEUE: RefCell<Vec<TooltipRequest>> = const { RefCell::new(Vec::new()) };
69}
70
71/// A wrapper widget that shows a text tooltip on hover.
72pub struct Tooltip {
73    bounds: Rect,
74    /// The wrapped child widget is stored in `children[0]`.
75    children: Vec<Box<dyn Widget>>,
76    base: WidgetBase,
77
78    /// Time when the pointer entered the widget.  `None` when the
79    /// pointer is outside. Wall-clock timing gives consistent tooltip
80    /// latency even when the app is not repainting continuously.
81    hover_started_at: Option<Instant>,
82    /// Whether the cursor is currently inside the widget bounds.
83    hovered: bool,
84    /// Whether this tooltip was visible on the previous paint. Used
85    /// to invalidate when the delayed tooltip appears or disappears,
86    /// not just when hover state changes.
87    tooltip_visible: bool,
88    /// Last known cursor position in local coordinates.
89    cursor: Point,
90
91    font: Arc<Font>,
92    lines: Vec<TooltipLine>,
93    disabled_lines: Vec<TooltipLine>,
94    disabled_when: Option<Rc<dyn Fn() -> bool>>,
95    at_pointer: bool,
96}
97
98impl Tooltip {
99    /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
100    pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
101        Self {
102            bounds: Rect::default(),
103            children: vec![child],
104            base: WidgetBase::new(),
105            hover_started_at: None,
106            hovered: false,
107            tooltip_visible: false,
108            cursor: Point::ORIGIN,
109            font,
110            lines: text_to_lines(text),
111            disabled_lines: Vec::new(),
112            disabled_when: None,
113            at_pointer: true,
114        }
115    }
116
117    /// Add another hover text block, matching egui's ability to chain
118    /// `.on_hover_text(...)` calls.
119    pub fn with_text(mut self, text: impl Into<String>) -> Self {
120        self.lines.extend(text_to_lines(text));
121        self
122    }
123
124    /// Add a code-styled line to the tooltip.
125    pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
126        self.lines.push(TooltipLine {
127            text: text.into(),
128            kind: TooltipLineKind::Code,
129        });
130        self
131    }
132
133    /// Add a link-styled line to the tooltip.  Tooltip overlays are
134    /// informational; the line is styled like a link but does not receive
135    /// pointer events.
136    pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
137        self.lines.push(TooltipLine {
138            text: text.into(),
139            kind: TooltipLineKind::Link,
140        });
141        self
142    }
143
144    /// Place the tooltip relative to the mouse cursor instead of the widget.
145    /// This is the default; kept for call-site clarity.
146    pub fn at_pointer(mut self) -> Self {
147        self.at_pointer = true;
148        self
149    }
150
151    /// Place the tooltip relative to the wrapped widget instead of the
152    /// mouse cursor.
153    pub fn at_widget(mut self) -> Self {
154        self.at_pointer = false;
155        self
156    }
157
158    /// Use alternate tooltip text while `disabled_when` returns true.
159    pub fn with_disabled_text(
160        mut self,
161        text: impl Into<String>,
162        disabled_when: impl Fn() -> bool + 'static,
163    ) -> Self {
164        self.disabled_lines = text_to_lines(text);
165        self.disabled_when = Some(Rc::new(disabled_when));
166        self
167    }
168
169    pub fn with_margin(mut self, m: Insets) -> Self {
170        self.base.margin = m;
171        self
172    }
173    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
174        self.base.h_anchor = h;
175        self
176    }
177    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
178        self.base.v_anchor = v;
179        self
180    }
181
182    fn show_tip(&self) -> bool {
183        self.hovered
184            && self
185                .hover_started_at
186                .map(|started| started.elapsed() >= TOOLTIP_INITIAL_DELAY)
187                .unwrap_or(false)
188    }
189
190    fn remaining_delay(&self) -> Option<Duration> {
191        if !self.hovered {
192            return None;
193        }
194        let elapsed = self.hover_started_at?.elapsed();
195        Some(TOOLTIP_INITIAL_DELAY.saturating_sub(elapsed))
196    }
197
198    fn active_lines(&self) -> Vec<TooltipLine> {
199        if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
200            && !self.disabled_lines.is_empty()
201        {
202            self.disabled_lines.clone()
203        } else {
204            self.lines.clone()
205        }
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::event::MouseButton;
213    use crate::text::Font;
214    use std::sync::atomic::{AtomicUsize, Ordering};
215
216    const FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/NotoSans-Regular.ttf");
217
218    struct ClickChild {
219        bounds: Rect,
220        children: Vec<Box<dyn Widget>>,
221        clicks: Arc<AtomicUsize>,
222    }
223
224    impl ClickChild {
225        fn new(clicks: Arc<AtomicUsize>) -> Self {
226            Self {
227                bounds: Rect::default(),
228                children: Vec::new(),
229                clicks,
230            }
231        }
232    }
233
234    impl Widget for ClickChild {
235        fn bounds(&self) -> Rect {
236            self.bounds
237        }
238        fn set_bounds(&mut self, bounds: Rect) {
239            self.bounds = bounds;
240        }
241        fn children(&self) -> &[Box<dyn Widget>] {
242            &self.children
243        }
244        fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
245            &mut self.children
246        }
247        fn type_name(&self) -> &'static str {
248            "ClickChild"
249        }
250        fn layout(&mut self, available: Size) -> Size {
251            self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
252            available
253        }
254        fn paint(&mut self, _ctx: &mut dyn DrawCtx) {}
255        fn on_event(&mut self, event: &Event) -> EventResult {
256            if let Event::MouseUp {
257                button: MouseButton::Left,
258                ..
259            } = event
260            {
261                self.clicks.fetch_add(1, Ordering::SeqCst);
262                EventResult::Consumed
263            } else {
264                EventResult::Ignored
265            }
266        }
267    }
268
269    #[test]
270    fn tooltip_forwards_clicks_to_wrapped_child() {
271        let clicks = Arc::new(AtomicUsize::new(0));
272        let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
273        let mut tooltip = Tooltip::new(Box::new(ClickChild::new(clicks.clone())), "tip", font);
274        tooltip.layout(Size::new(20.0, 20.0));
275        let event = Event::MouseUp {
276            pos: Point::new(10.0, 10.0),
277            button: MouseButton::Left,
278            modifiers: Default::default(),
279        };
280        assert_eq!(tooltip.on_event(&event), EventResult::Consumed);
281        assert_eq!(clicks.load(Ordering::SeqCst), 1);
282    }
283
284    #[test]
285    fn tooltip_defaults_to_pointer_anchored() {
286        let clicks = Arc::new(AtomicUsize::new(0));
287        let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
288        let tooltip = Tooltip::new(Box::new(ClickChild::new(clicks)), "tip", font);
289        assert!(tooltip.at_pointer);
290    }
291
292    #[test]
293    fn tooltip_can_opt_into_widget_anchor() {
294        let clicks = Arc::new(AtomicUsize::new(0));
295        let font = Arc::new(Font::from_bytes(FONT_BYTES.to_vec()).expect("bundled font"));
296        let tooltip = Tooltip::new(Box::new(ClickChild::new(clicks)), "tip", font).at_widget();
297        assert!(!tooltip.at_pointer);
298    }
299}
300
301impl Widget for Tooltip {
302    fn type_name(&self) -> &'static str {
303        "Tooltip"
304    }
305    fn bounds(&self) -> Rect {
306        self.bounds
307    }
308    fn set_bounds(&mut self, b: Rect) {
309        self.bounds = b;
310    }
311    fn children(&self) -> &[Box<dyn Widget>] {
312        &self.children
313    }
314    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
315        &mut self.children
316    }
317
318    fn margin(&self) -> Insets {
319        self.base.margin
320    }
321    fn widget_base(&self) -> Option<&WidgetBase> {
322        Some(&self.base)
323    }
324    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
325        Some(&mut self.base)
326    }
327    fn h_anchor(&self) -> HAnchor {
328        self.base.h_anchor
329    }
330    fn v_anchor(&self) -> VAnchor {
331        self.base.v_anchor
332    }
333
334    fn is_focusable(&self) -> bool {
335        self.children
336            .first()
337            .map(|c| c.is_focusable())
338            .unwrap_or(false)
339    }
340
341    fn layout(&mut self, available: Size) -> Size {
342        let s = if let Some(child) = self.children.first_mut() {
343            let cs = child.layout(available);
344            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
345            cs
346        } else {
347            available
348        };
349        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
350        s
351    }
352
353    fn paint(&mut self, _: &mut dyn DrawCtx) {}
354
355    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
356        let should_show = self.show_tip();
357
358        if self.hovered && !should_show {
359            if let Some(remaining) = self.remaining_delay() {
360                if remaining.is_zero() {
361                    crate::animation::request_draw();
362                } else {
363                    crate::animation::request_draw_after(remaining);
364                }
365            }
366        }
367
368        if should_show != self.tooltip_visible {
369            self.tooltip_visible = should_show;
370            // The visible tooltip is a global overlay, but the request
371            // is produced by this widget during paint.  Bump the normal
372            // invalidation path so retained ancestors and the global
373            // tooltip queue redraw when the delayed tooltip appears or
374            // disappears.
375            crate::animation::request_draw();
376        }
377
378        if !should_show {
379            return;
380        }
381
382        let anchor = if self.at_pointer {
383            current_mouse_world().unwrap_or(self.cursor)
384        } else {
385            let mut x = self.bounds.width * 0.5;
386            // Widget-anchored tooltips should appear below the
387            // hovered widget by default (MatterCAD-style). In
388            // agg-gui's Y-up coords, the bottom edge is y=0; the
389            // global paint step will offset the panel by
390            // `TOOLTIP_GAP` from this anchor.
391            let mut y = 0.0;
392            ctx.root_transform().transform(&mut x, &mut y);
393            Point::new(x, y)
394        };
395        submit_tooltip(TooltipRequest {
396            font: Arc::clone(&self.font),
397            lines: self.active_lines(),
398            anchor,
399            at_pointer: self.at_pointer,
400        });
401    }
402
403    fn on_event(&mut self, event: &Event) -> EventResult {
404        match event {
405            Event::MouseMove { pos } => {
406                let was = self.hovered;
407                self.hovered = self.hit_test(*pos);
408                self.cursor = *pos;
409                if self.hovered && !was {
410                    self.hover_started_at = Some(Instant::now());
411                    crate::animation::request_draw_after(TOOLTIP_INITIAL_DELAY);
412                } else if !self.hovered {
413                    self.hover_started_at = None;
414                    if self.tooltip_visible {
415                        self.tooltip_visible = false;
416                        crate::animation::request_draw();
417                    }
418                }
419                if self.hovered != was {
420                    crate::animation::request_draw();
421                }
422                self.children
423                    .first_mut()
424                    .map(|child| child.on_event(event))
425                    .unwrap_or(EventResult::Ignored)
426            }
427            Event::MouseWheel { .. } => {
428                self.hovered = false;
429                self.hover_started_at = None;
430                if self.tooltip_visible {
431                    self.tooltip_visible = false;
432                    crate::animation::request_draw();
433                }
434                self.children
435                    .first_mut()
436                    .map(|child| child.on_event(event))
437                    .unwrap_or(EventResult::Ignored)
438            }
439            _ => self
440                .children
441                .first_mut()
442                .map(|child| child.on_event(event))
443                .unwrap_or(EventResult::Ignored),
444        }
445    }
446
447    fn hit_test(&self, local_pos: Point) -> bool {
448        local_pos.x >= 0.0
449            && local_pos.x <= self.bounds.width
450            && local_pos.y >= 0.0
451            && local_pos.y <= self.bounds.height
452    }
453}
454
455fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
456    text.into()
457        .lines()
458        .map(|line| TooltipLine {
459            text: line.to_owned(),
460            kind: TooltipLineKind::Text,
461        })
462        .collect()
463}
464
465fn submit_tooltip(request: TooltipRequest) {
466    TOOLTIP_QUEUE.with(|q| q.borrow_mut().push(request));
467}
468
469pub(crate) fn begin_tooltip_frame() {
470    TOOLTIP_QUEUE.with(|q| q.borrow_mut().clear());
471}
472
473pub(crate) fn paint_global_tooltips(ctx: &mut dyn DrawCtx, viewport: Size) {
474    let requests = TOOLTIP_QUEUE.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
475    for request in requests {
476        paint_request(ctx, viewport, request);
477    }
478}
479
480fn paint_request(ctx: &mut dyn DrawCtx, viewport: Size, request: TooltipRequest) {
481    if request.lines.is_empty() {
482        return;
483    }
484
485    let v = ctx.visuals();
486    ctx.set_font(Arc::clone(&request.font));
487    ctx.set_font_size(TOOLTIP_FONT_SIZE);
488
489    let line_h = TOOLTIP_FONT_SIZE * 1.45;
490    let mut max_w = 0.0_f64;
491    for line in &request.lines {
492        if let Some(m) = ctx.measure_text(&line.text) {
493            max_w = max_w.max(m.width);
494        }
495    }
496
497    let panel_w = (max_w + TOOLTIP_PAD_X * 2.0).max(64.0);
498    let panel_h = request.lines.len() as f64 * line_h + TOOLTIP_PAD_Y * 2.0;
499    let mut panel_x = if request.at_pointer {
500        request.anchor.x
501    } else {
502        request.anchor.x - panel_w * 0.5
503    };
504    let mut panel_y = request.anchor.y - panel_h - TOOLTIP_GAP;
505    if request.at_pointer {
506        panel_y -= POINTER_TOOLTIP_EXTRA_DROP;
507    }
508
509    if panel_x + panel_w > viewport.width - SCREEN_MARGIN {
510        panel_x = viewport.width - panel_w - SCREEN_MARGIN;
511    }
512    if panel_y < SCREEN_MARGIN {
513        // If there is not enough room below, fall back above the
514        // cursor / widget, mirroring viewport-edge avoidance.
515        panel_y = request.anchor.y + TOOLTIP_GAP;
516    }
517    panel_x = panel_x.clamp(
518        SCREEN_MARGIN,
519        (viewport.width - panel_w - SCREEN_MARGIN).max(SCREEN_MARGIN),
520    );
521    panel_y = panel_y.clamp(
522        SCREEN_MARGIN,
523        (viewport.height - panel_h - SCREEN_MARGIN).max(SCREEN_MARGIN),
524    );
525
526    ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
527    ctx.begin_path();
528    ctx.rounded_rect(panel_x + 1.0, panel_y - 1.0, panel_w, panel_h, 5.0);
529    ctx.fill();
530
531    ctx.set_fill_color(v.window_fill);
532    ctx.begin_path();
533    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
534    ctx.fill();
535
536    ctx.set_stroke_color(v.widget_stroke);
537    ctx.set_line_width(1.0);
538    ctx.begin_path();
539    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
540    ctx.stroke();
541
542    for (i, line) in request.lines.iter().enumerate() {
543        let y = panel_y + panel_h - TOOLTIP_PAD_Y - (i as f64 + 1.0) * line_h + 2.0;
544        match line.kind {
545            TooltipLineKind::Text => {
546                ctx.set_fill_color(v.text_color);
547                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
548            }
549            TooltipLineKind::Code => {
550                if let Some(m) = ctx.measure_text(&line.text) {
551                    ctx.set_fill_color(v.track_bg);
552                    ctx.begin_path();
553                    ctx.rounded_rect(
554                        panel_x + TOOLTIP_PAD_X - 3.0,
555                        y - 3.0,
556                        m.width + 6.0,
557                        line_h,
558                        3.0,
559                    );
560                    ctx.fill();
561                }
562                ctx.set_fill_color(v.text_color);
563                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
564            }
565            TooltipLineKind::Link => {
566                ctx.set_fill_color(v.text_link);
567                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
568                if let Some(m) = ctx.measure_text(&line.text) {
569                    ctx.set_stroke_color(v.text_link);
570                    ctx.set_line_width(1.0);
571                    ctx.begin_path();
572                    ctx.move_to(panel_x + TOOLTIP_PAD_X, y - 2.0);
573                    ctx.line_to(panel_x + TOOLTIP_PAD_X + m.width, y - 2.0);
574                    ctx.stroke();
575                }
576            }
577        }
578    }
579}