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;
21
22use crate::color::Color;
23use crate::draw_ctx::DrawCtx;
24use crate::event::{Event, EventResult};
25use crate::geometry::{Point, Rect, Size};
26use crate::layout_props::{HAnchor, Insets, VAnchor, WidgetBase};
27use crate::text::Font;
28use crate::widget::{current_mouse_world, Widget};
29
30/// Number of consecutive hovered frames before the tooltip appears.
31/// At ~60 fps this gives an egui-like short hover delay.
32const HOVER_DELAY_FRAMES: u32 = 18;
33const TOOLTIP_FONT_SIZE: f64 = 12.0;
34const TOOLTIP_PAD_X: f64 = 8.0;
35const TOOLTIP_PAD_Y: f64 = 6.0;
36const TOOLTIP_GAP: f64 = 4.0;
37const SCREEN_MARGIN: f64 = 4.0;
38
39#[derive(Clone)]
40enum TooltipLineKind {
41    Text,
42    Code,
43    Link,
44}
45
46#[derive(Clone)]
47struct TooltipLine {
48    text: String,
49    kind: TooltipLineKind,
50}
51
52struct TooltipRequest {
53    font: Arc<Font>,
54    lines: Vec<TooltipLine>,
55    anchor: Point,
56    at_pointer: bool,
57}
58
59thread_local! {
60    static TOOLTIP_QUEUE: RefCell<Vec<TooltipRequest>> = const { RefCell::new(Vec::new()) };
61}
62
63/// A wrapper widget that shows a text tooltip on hover.
64pub struct Tooltip {
65    bounds: Rect,
66    /// The wrapped child widget is stored in `children[0]`.
67    children: Vec<Box<dyn Widget>>,
68    base: WidgetBase,
69
70    /// Hover-frame counter: increments while cursor is over the child.
71    hover_frames: u32,
72    /// Whether the cursor is currently inside the widget bounds.
73    hovered: bool,
74    /// Last known cursor position in local coordinates.
75    cursor: Point,
76
77    font: Arc<Font>,
78    lines: Vec<TooltipLine>,
79    disabled_lines: Vec<TooltipLine>,
80    disabled_when: Option<Rc<dyn Fn() -> bool>>,
81    at_pointer: bool,
82}
83
84impl Tooltip {
85    /// Create a new `Tooltip` wrapping `child` with `text` as the tip message.
86    pub fn new(child: Box<dyn Widget>, text: impl Into<String>, font: Arc<Font>) -> Self {
87        Self {
88            bounds: Rect::default(),
89            children: vec![child],
90            base: WidgetBase::new(),
91            hover_frames: 0,
92            hovered: false,
93            cursor: Point::ORIGIN,
94            font,
95            lines: text_to_lines(text),
96            disabled_lines: Vec::new(),
97            disabled_when: None,
98            at_pointer: false,
99        }
100    }
101
102    /// Add another hover text block, matching egui's ability to chain
103    /// `.on_hover_text(...)` calls.
104    pub fn with_text(mut self, text: impl Into<String>) -> Self {
105        self.lines.extend(text_to_lines(text));
106        self
107    }
108
109    /// Add a code-styled line to the tooltip.
110    pub fn with_code_line(mut self, text: impl Into<String>) -> Self {
111        self.lines.push(TooltipLine {
112            text: text.into(),
113            kind: TooltipLineKind::Code,
114        });
115        self
116    }
117
118    /// Add a link-styled line to the tooltip.  Tooltip overlays are
119    /// informational; the line is styled like a link but does not receive
120    /// pointer events.
121    pub fn with_link_line(mut self, text: impl Into<String>) -> Self {
122        self.lines.push(TooltipLine {
123            text: text.into(),
124            kind: TooltipLineKind::Link,
125        });
126        self
127    }
128
129    /// Place the tooltip relative to the mouse cursor instead of the widget.
130    pub fn at_pointer(mut self) -> Self {
131        self.at_pointer = true;
132        self
133    }
134
135    /// Use alternate tooltip text while `disabled_when` returns true.
136    pub fn with_disabled_text(
137        mut self,
138        text: impl Into<String>,
139        disabled_when: impl Fn() -> bool + 'static,
140    ) -> Self {
141        self.disabled_lines = text_to_lines(text);
142        self.disabled_when = Some(Rc::new(disabled_when));
143        self
144    }
145
146    pub fn with_margin(mut self, m: Insets) -> Self {
147        self.base.margin = m;
148        self
149    }
150    pub fn with_h_anchor(mut self, h: HAnchor) -> Self {
151        self.base.h_anchor = h;
152        self
153    }
154    pub fn with_v_anchor(mut self, v: VAnchor) -> Self {
155        self.base.v_anchor = v;
156        self
157    }
158
159    fn show_tip(&self) -> bool {
160        self.hovered && self.hover_frames >= HOVER_DELAY_FRAMES
161    }
162
163    fn active_lines(&self) -> Vec<TooltipLine> {
164        if self.disabled_when.as_ref().map(|f| f()).unwrap_or(false)
165            && !self.disabled_lines.is_empty()
166        {
167            self.disabled_lines.clone()
168        } else {
169            self.lines.clone()
170        }
171    }
172}
173
174impl Widget for Tooltip {
175    fn type_name(&self) -> &'static str {
176        "Tooltip"
177    }
178    fn bounds(&self) -> Rect {
179        self.bounds
180    }
181    fn set_bounds(&mut self, b: Rect) {
182        self.bounds = b;
183    }
184    fn children(&self) -> &[Box<dyn Widget>] {
185        &self.children
186    }
187    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
188        &mut self.children
189    }
190
191    fn margin(&self) -> Insets {
192        self.base.margin
193    }
194    fn widget_base(&self) -> Option<&WidgetBase> {
195        Some(&self.base)
196    }
197    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
198        Some(&mut self.base)
199    }
200    fn h_anchor(&self) -> HAnchor {
201        self.base.h_anchor
202    }
203    fn v_anchor(&self) -> VAnchor {
204        self.base.v_anchor
205    }
206
207    fn is_focusable(&self) -> bool {
208        self.children
209            .first()
210            .map(|c| c.is_focusable())
211            .unwrap_or(false)
212    }
213
214    fn layout(&mut self, available: Size) -> Size {
215        let s = if let Some(child) = self.children.first_mut() {
216            let cs = child.layout(available);
217            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
218            cs
219        } else {
220            available
221        };
222        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
223        s
224    }
225
226    fn paint(&mut self, _: &mut dyn DrawCtx) {}
227
228    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
229        if self.hovered {
230            self.hover_frames = self.hover_frames.saturating_add(1);
231            if !self.show_tip() {
232                crate::animation::request_draw();
233            }
234        }
235
236        if !self.show_tip() {
237            return;
238        }
239
240        let mut anchor = if self.at_pointer {
241            current_mouse_world().unwrap_or(self.cursor)
242        } else {
243            let mut x = self.bounds.width * 0.5;
244            let mut y = self.bounds.height;
245            ctx.root_transform().transform(&mut x, &mut y);
246            Point::new(x, y)
247        };
248        if self.at_pointer {
249            anchor.x += 14.0;
250            anchor.y += 14.0;
251        }
252
253        submit_tooltip(TooltipRequest {
254            font: Arc::clone(&self.font),
255            lines: self.active_lines(),
256            anchor,
257            at_pointer: self.at_pointer,
258        });
259    }
260
261    fn on_event(&mut self, event: &Event) -> EventResult {
262        match event {
263            Event::MouseMove { pos } => {
264                let was = self.hovered;
265                self.hovered = self.hit_test(*pos);
266                self.cursor = *pos;
267                if !self.hovered {
268                    self.hover_frames = 0;
269                }
270                if self.hovered != was {
271                    crate::animation::request_draw();
272                }
273                EventResult::Ignored
274            }
275            Event::MouseWheel { .. } => {
276                self.hovered = false;
277                self.hover_frames = 0;
278                EventResult::Ignored
279            }
280            _ => EventResult::Ignored,
281        }
282    }
283
284    fn hit_test(&self, local_pos: Point) -> bool {
285        local_pos.x >= 0.0
286            && local_pos.x <= self.bounds.width
287            && local_pos.y >= 0.0
288            && local_pos.y <= self.bounds.height
289    }
290}
291
292fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
293    text.into()
294        .lines()
295        .map(|line| TooltipLine {
296            text: line.to_owned(),
297            kind: TooltipLineKind::Text,
298        })
299        .collect()
300}
301
302fn submit_tooltip(request: TooltipRequest) {
303    TOOLTIP_QUEUE.with(|q| q.borrow_mut().push(request));
304}
305
306pub(crate) fn begin_tooltip_frame() {
307    TOOLTIP_QUEUE.with(|q| q.borrow_mut().clear());
308}
309
310pub(crate) fn paint_global_tooltips(ctx: &mut dyn DrawCtx, viewport: Size) {
311    let requests = TOOLTIP_QUEUE.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
312    for request in requests {
313        paint_request(ctx, viewport, request);
314    }
315}
316
317fn paint_request(ctx: &mut dyn DrawCtx, viewport: Size, request: TooltipRequest) {
318    if request.lines.is_empty() {
319        return;
320    }
321
322    let v = ctx.visuals();
323    ctx.set_font(Arc::clone(&request.font));
324    ctx.set_font_size(TOOLTIP_FONT_SIZE);
325
326    let line_h = TOOLTIP_FONT_SIZE * 1.45;
327    let mut max_w = 0.0_f64;
328    for line in &request.lines {
329        if let Some(m) = ctx.measure_text(&line.text) {
330            max_w = max_w.max(m.width);
331        }
332    }
333
334    let panel_w = (max_w + TOOLTIP_PAD_X * 2.0).max(64.0);
335    let panel_h = request.lines.len() as f64 * line_h + TOOLTIP_PAD_Y * 2.0;
336    let mut panel_x = if request.at_pointer {
337        request.anchor.x
338    } else {
339        request.anchor.x - panel_w * 0.5
340    };
341    let mut panel_y = request.anchor.y + TOOLTIP_GAP;
342
343    if panel_x + panel_w > viewport.width - SCREEN_MARGIN {
344        panel_x = viewport.width - panel_w - SCREEN_MARGIN;
345    }
346    if panel_y + panel_h > viewport.height - SCREEN_MARGIN {
347        panel_y = request.anchor.y - panel_h - TOOLTIP_GAP * 3.0;
348    }
349    panel_x = panel_x.clamp(
350        SCREEN_MARGIN,
351        (viewport.width - panel_w - SCREEN_MARGIN).max(SCREEN_MARGIN),
352    );
353    panel_y = panel_y.clamp(
354        SCREEN_MARGIN,
355        (viewport.height - panel_h - SCREEN_MARGIN).max(SCREEN_MARGIN),
356    );
357
358    ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
359    ctx.begin_path();
360    ctx.rounded_rect(panel_x + 1.0, panel_y - 1.0, panel_w, panel_h, 5.0);
361    ctx.fill();
362
363    ctx.set_fill_color(v.window_fill);
364    ctx.begin_path();
365    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
366    ctx.fill();
367
368    ctx.set_stroke_color(v.widget_stroke);
369    ctx.set_line_width(1.0);
370    ctx.begin_path();
371    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
372    ctx.stroke();
373
374    for (i, line) in request.lines.iter().enumerate() {
375        let y = panel_y + panel_h - TOOLTIP_PAD_Y - (i as f64 + 1.0) * line_h + 2.0;
376        match line.kind {
377            TooltipLineKind::Text => {
378                ctx.set_fill_color(v.text_color);
379                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
380            }
381            TooltipLineKind::Code => {
382                if let Some(m) = ctx.measure_text(&line.text) {
383                    ctx.set_fill_color(v.track_bg);
384                    ctx.begin_path();
385                    ctx.rounded_rect(
386                        panel_x + TOOLTIP_PAD_X - 3.0,
387                        y - 3.0,
388                        m.width + 6.0,
389                        line_h,
390                        3.0,
391                    );
392                    ctx.fill();
393                }
394                ctx.set_fill_color(v.text_color);
395                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
396            }
397            TooltipLineKind::Link => {
398                ctx.set_fill_color(v.text_link);
399                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
400                if let Some(m) = ctx.measure_text(&line.text) {
401                    ctx.set_stroke_color(v.text_link);
402                    ctx.set_line_width(1.0);
403                    ctx.begin_path();
404                    ctx.move_to(panel_x + TOOLTIP_PAD_X, y - 2.0);
405                    ctx.line_to(panel_x + TOOLTIP_PAD_X + m.width, y - 2.0);
406                    ctx.stroke();
407                }
408            }
409        }
410    }
411}