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 h_anchor(&self) -> HAnchor {
195        self.base.h_anchor
196    }
197    fn v_anchor(&self) -> VAnchor {
198        self.base.v_anchor
199    }
200
201    fn is_focusable(&self) -> bool {
202        self.children
203            .first()
204            .map(|c| c.is_focusable())
205            .unwrap_or(false)
206    }
207
208    fn layout(&mut self, available: Size) -> Size {
209        let s = if let Some(child) = self.children.first_mut() {
210            let cs = child.layout(available);
211            child.set_bounds(Rect::new(0.0, 0.0, cs.width, cs.height));
212            cs
213        } else {
214            available
215        };
216        self.bounds = Rect::new(0.0, 0.0, s.width, s.height);
217        s
218    }
219
220    fn paint(&mut self, _: &mut dyn DrawCtx) {}
221
222    fn paint_overlay(&mut self, ctx: &mut dyn DrawCtx) {
223        if self.hovered {
224            self.hover_frames = self.hover_frames.saturating_add(1);
225            if !self.show_tip() {
226                crate::animation::request_draw();
227            }
228        }
229
230        if !self.show_tip() {
231            return;
232        }
233
234        let mut anchor = if self.at_pointer {
235            current_mouse_world().unwrap_or(self.cursor)
236        } else {
237            let mut x = self.bounds.width * 0.5;
238            let mut y = self.bounds.height;
239            ctx.root_transform().transform(&mut x, &mut y);
240            Point::new(x, y)
241        };
242        if self.at_pointer {
243            anchor.x += 14.0;
244            anchor.y += 14.0;
245        }
246
247        submit_tooltip(TooltipRequest {
248            font: Arc::clone(&self.font),
249            lines: self.active_lines(),
250            anchor,
251            at_pointer: self.at_pointer,
252        });
253    }
254
255    fn on_event(&mut self, event: &Event) -> EventResult {
256        match event {
257            Event::MouseMove { pos } => {
258                let was = self.hovered;
259                self.hovered = self.hit_test(*pos);
260                self.cursor = *pos;
261                if !self.hovered {
262                    self.hover_frames = 0;
263                }
264                if self.hovered != was {
265                    crate::animation::request_draw();
266                }
267                EventResult::Ignored
268            }
269            Event::MouseWheel { .. } => {
270                self.hovered = false;
271                self.hover_frames = 0;
272                EventResult::Ignored
273            }
274            _ => EventResult::Ignored,
275        }
276    }
277
278    fn hit_test(&self, local_pos: Point) -> bool {
279        local_pos.x >= 0.0
280            && local_pos.x <= self.bounds.width
281            && local_pos.y >= 0.0
282            && local_pos.y <= self.bounds.height
283    }
284}
285
286fn text_to_lines(text: impl Into<String>) -> Vec<TooltipLine> {
287    text.into()
288        .lines()
289        .map(|line| TooltipLine {
290            text: line.to_owned(),
291            kind: TooltipLineKind::Text,
292        })
293        .collect()
294}
295
296fn submit_tooltip(request: TooltipRequest) {
297    TOOLTIP_QUEUE.with(|q| q.borrow_mut().push(request));
298}
299
300pub(crate) fn begin_tooltip_frame() {
301    TOOLTIP_QUEUE.with(|q| q.borrow_mut().clear());
302}
303
304pub(crate) fn paint_global_tooltips(ctx: &mut dyn DrawCtx, viewport: Size) {
305    let requests = TOOLTIP_QUEUE.with(|q| q.borrow_mut().drain(..).collect::<Vec<_>>());
306    for request in requests {
307        paint_request(ctx, viewport, request);
308    }
309}
310
311fn paint_request(ctx: &mut dyn DrawCtx, viewport: Size, request: TooltipRequest) {
312    if request.lines.is_empty() {
313        return;
314    }
315
316    let v = ctx.visuals();
317    ctx.set_font(Arc::clone(&request.font));
318    ctx.set_font_size(TOOLTIP_FONT_SIZE);
319
320    let line_h = TOOLTIP_FONT_SIZE * 1.45;
321    let mut max_w = 0.0_f64;
322    for line in &request.lines {
323        if let Some(m) = ctx.measure_text(&line.text) {
324            max_w = max_w.max(m.width);
325        }
326    }
327
328    let panel_w = (max_w + TOOLTIP_PAD_X * 2.0).max(64.0);
329    let panel_h = request.lines.len() as f64 * line_h + TOOLTIP_PAD_Y * 2.0;
330    let mut panel_x = if request.at_pointer {
331        request.anchor.x
332    } else {
333        request.anchor.x - panel_w * 0.5
334    };
335    let mut panel_y = request.anchor.y + TOOLTIP_GAP;
336
337    if panel_x + panel_w > viewport.width - SCREEN_MARGIN {
338        panel_x = viewport.width - panel_w - SCREEN_MARGIN;
339    }
340    if panel_y + panel_h > viewport.height - SCREEN_MARGIN {
341        panel_y = request.anchor.y - panel_h - TOOLTIP_GAP * 3.0;
342    }
343    panel_x = panel_x.clamp(
344        SCREEN_MARGIN,
345        (viewport.width - panel_w - SCREEN_MARGIN).max(SCREEN_MARGIN),
346    );
347    panel_y = panel_y.clamp(
348        SCREEN_MARGIN,
349        (viewport.height - panel_h - SCREEN_MARGIN).max(SCREEN_MARGIN),
350    );
351
352    ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
353    ctx.begin_path();
354    ctx.rounded_rect(panel_x + 1.0, panel_y - 1.0, panel_w, panel_h, 5.0);
355    ctx.fill();
356
357    ctx.set_fill_color(v.window_fill);
358    ctx.begin_path();
359    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
360    ctx.fill();
361
362    ctx.set_stroke_color(v.widget_stroke);
363    ctx.set_line_width(1.0);
364    ctx.begin_path();
365    ctx.rounded_rect(panel_x, panel_y, panel_w, panel_h, 5.0);
366    ctx.stroke();
367
368    for (i, line) in request.lines.iter().enumerate() {
369        let y = panel_y + panel_h - TOOLTIP_PAD_Y - (i as f64 + 1.0) * line_h + 2.0;
370        match line.kind {
371            TooltipLineKind::Text => {
372                ctx.set_fill_color(v.text_color);
373                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
374            }
375            TooltipLineKind::Code => {
376                if let Some(m) = ctx.measure_text(&line.text) {
377                    ctx.set_fill_color(v.track_bg);
378                    ctx.begin_path();
379                    ctx.rounded_rect(
380                        panel_x + TOOLTIP_PAD_X - 3.0,
381                        y - 3.0,
382                        m.width + 6.0,
383                        line_h,
384                        3.0,
385                    );
386                    ctx.fill();
387                }
388                ctx.set_fill_color(v.text_color);
389                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
390            }
391            TooltipLineKind::Link => {
392                ctx.set_fill_color(v.text_link);
393                ctx.fill_text(&line.text, panel_x + TOOLTIP_PAD_X, y);
394                if let Some(m) = ctx.measure_text(&line.text) {
395                    ctx.set_stroke_color(v.text_link);
396                    ctx.set_line_width(1.0);
397                    ctx.begin_path();
398                    ctx.move_to(panel_x + TOOLTIP_PAD_X, y - 2.0);
399                    ctx.line_to(panel_x + TOOLTIP_PAD_X + m.width, y - 2.0);
400                    ctx.stroke();
401                }
402            }
403        }
404    }
405}