Skip to main content

agg_gui/widgets/
tooltip.rs

1//! `Tooltip` — a wrapper widget that shows a hover tooltip.
2//!
3//! Wraps any child widget and renders a small info panel near the cursor when
4//! the user hovers over the child.  The panel appears after a short delay
5//! (`HOVER_DELAY_FRAMES`) and is rendered inline within the widget's own `paint()`
6//! call, which means it renders within the widget's local clip region.
7//!
8//! # Limitations
9//!
10//! Because the tooltip is painted in local coordinate space it will be clipped by
11//! the parent `ScrollView` if the child is near the edge.  True floating tooltips
12//! require a global overlay layer.  This implementation covers the common case
13//! where the tooltip fits within the visible widget area.
14//!
15//! # Usage
16//!
17//! ```ignore
18//! Tooltip::new(
19//!     Box::new(Button::new("Hover me", font.clone()).on_click(|| {})),
20//!     "This is a tooltip",
21//!     font.clone(),
22//! )
23//! ```
24
25use std::sync::Arc;
26
27use crate::color::Color;
28use crate::draw_ctx::DrawCtx;
29use crate::event::{Event, EventResult};
30use crate::geometry::{Point, Rect, Size};
31use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
32use crate::text::Font;
33use crate::widget::{Widget, paint_subtree};
34use crate::widgets::label::Label;
35
36/// Number of consecutive hovered frames before the tooltip appears.
37/// At ~60 fps this gives a ~0.5 second delay.
38const HOVER_DELAY_FRAMES: u32 = 30;
39
40/// A wrapper widget that shows a text tooltip on hover.
41///
42/// The tooltip panel is drawn above the child widget (Y-up: higher Y = visually above).
43/// The text is rendered through a backbuffered [`Label`] child.
44pub struct Tooltip {
45    bounds:   Rect,
46    /// The wrapped child widget is stored in `children[0]`.
47    children: Vec<Box<dyn Widget>>,
48    base:     WidgetBase,
49
50    /// Hover-frame counter: increments while cursor is over the child.
51    hover_frames: u32,
52    /// Whether the cursor is currently inside the widget bounds.
53    hovered: bool,
54    /// Last known cursor position in local coordinates.
55    cursor: Point,
56
57    /// Backbuffered label for the tooltip text.
58    tip_label: Label,
59}
60
61impl Tooltip {
62    /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
63    pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
64        Self {
65            bounds:       Rect::default(),
66            children:     vec![child],
67            base:         WidgetBase::new(),
68            hover_frames: 0,
69            hovered:      false,
70            cursor:       Point::ORIGIN,
71            tip_label:    Label::new(text, font).with_font_size(11.5),
72        }
73    }
74
75    pub fn with_margin(mut self, m: Insets)    -> Self { self.base.margin   = m; self }
76    pub fn with_h_anchor(mut self, h: HAnchor) -> Self { self.base.h_anchor = h; self }
77    pub fn with_v_anchor(mut self, v: VAnchor) -> Self { self.base.v_anchor = v; self }
78
79    fn show_tip(&self) -> bool {
80        self.hovered && self.hover_frames >= HOVER_DELAY_FRAMES
81    }
82}
83
84impl Widget for Tooltip {
85    fn type_name(&self) -> &'static str { "Tooltip" }
86    fn bounds(&self) -> Rect { self.bounds }
87    fn set_bounds(&mut self, b: Rect) { self.bounds = b; }
88    fn children(&self) -> &[Box<dyn Widget>] { &self.children }
89    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> { &mut self.children }
90
91    fn margin(&self)   -> Insets  { self.base.margin }
92    fn h_anchor(&self) -> HAnchor { self.base.h_anchor }
93    fn v_anchor(&self) -> VAnchor { self.base.v_anchor }
94
95    fn is_focusable(&self) -> bool {
96        self.children.first().map(|c| c.is_focusable()).unwrap_or(false)
97    }
98
99    fn layout(&mut self, available: Size) -> Size {
100        let s = if let Some(child) = self.children.first_mut() {
101            let cs = child.layout(available);
102            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
103            cs
104        } else {
105            available
106        };
107        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
108        s
109    }
110
111    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
112        // Increment hover counter each paint frame while hovered.
113        if self.hovered {
114            self.hover_frames = self.hover_frames.saturating_add(1);
115        }
116
117        // Paint the wrapped child widget.
118        if let Some(child) = self.children.first_mut() {
119            paint_subtree(child.as_mut(), ctx);
120        }
121
122        // Draw tooltip panel if hovered long enough.
123        if !self.show_tip() { return; }
124
125        let v = ctx.visuals();
126        let pad_x = 8.0_f64;
127        let pad_y = 5.0_f64;
128
129        // Layout the label.
130        let max_tip_w = self.bounds.width.max(120.0).min(260.0);
131        let ls = self.tip_label.layout(Size::new(max_tip_w, 100.0));
132
133        let panel_w = ls.width  + pad_x * 2.0;
134        let panel_h = ls.height + pad_y * 2.0;
135
136        // Position panel above the cursor (Y-up: above = larger Y).
137        let cursor_y = self.cursor.y;
138        let cursor_x = self.cursor.x;
139        let panel_x = (cursor_x - panel_w * 0.5).max(0.0).min(self.bounds.width - panel_w);
140        let panel_y = cursor_y + 10.0; // 10px above cursor in Y-up space
141
142        // Draw tooltip shadow.
143        ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
144        ctx.begin_path();
145        ctx.rounded_rect(panel_x + 1.0, panel_y - 1.0, panel_w, panel_h, 4.0);
146        ctx.fill();
147
148        // Draw tooltip panel background.
149        ctx.set_fill_color(v.window_fill);
150        ctx.begin_path();
151        ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 4.0);
152        ctx.fill();
153
154        // Border.
155        ctx.set_stroke_color(v.widget_stroke);
156        ctx.set_line_width(1.0);
157        ctx.begin_path();
158        ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 4.0);
159        ctx.stroke();
160
161        // Paint the label.
162        self.tip_label.set_color(v.text_color);
163        self.tip_label.set_bounds(Rect::new(0.0, 0.0, ls.width, ls.height));
164        let lx = panel_x + pad_x;
165        let ly = panel_y + pad_y;
166        ctx.save();
167        ctx.translate(lx, ly);
168        paint_subtree(&mut self.tip_label, ctx);
169        ctx.restore();
170    }
171
172    fn on_event(&mut self, event: &Event) -> EventResult {
173        match event {
174            Event::MouseMove { pos } => {
175                let was = self.hovered;
176                self.hovered = self.hit_test(*pos);
177                self.cursor = *pos;
178                if !self.hovered {
179                    self.hover_frames = 0;
180                }
181                // Forward to child.
182                let result = if let Some(child) = self.children.first_mut() {
183                    child.on_event(event)
184                } else {
185                    EventResult::Ignored
186                };
187                // If hover state changed, request a repaint and consume.
188                if self.hovered != was {
189                    crate::animation::request_tick();
190                    EventResult::Consumed
191                } else { result }
192            }
193            _ => {
194                if let Some(child) = self.children.first_mut() {
195                    child.on_event(event)
196                } else {
197                    EventResult::Ignored
198                }
199            }
200        }
201    }
202
203    fn hit_test(&self, local_pos: Point) -> bool {
204        local_pos.x >= 0.0 && local_pos.x <= self.bounds.width
205            && local_pos.y >= 0.0 && local_pos.y <= self.bounds.height
206    }
207}