Skip to main content

elegance/
tooltip.rs

1//! Tooltip — a hover-triggered, themed callout that explains a trigger widget.
2//!
3//! A [`Tooltip`] attaches to any [`Response`] and surfaces a small bordered
4//! card after a short hover delay. The card has a required body line plus an
5//! optional bold heading and an optional shortcut row (label + key chips).
6//! Reach for it for one-line labels on icon buttons, "what is this?" hints
7//! next to field labels, and short explanations of status pills.
8//!
9//! Visibility is driven by egui's tooltip system, so all the standard niceties
10//! come for free: a delay before the first tooltip in a session, a grace
11//! window after that during which moving onto a sibling shows its tooltip
12//! immediately, and dismiss-on-click / dismiss-on-scroll. For a
13//! click-anchored panel that the user can interact with, reach for
14//! [`Popover`](crate::Popover) instead.
15//!
16//! ```no_run
17//! # use elegance::{Button, Tooltip};
18//! # egui::__run_test_ui(|ui| {
19//! let trigger = ui.add(Button::new("Save"));
20//! Tooltip::new("Write the working tree to disk.")
21//!     .heading("Save changes")
22//!     .shortcut("\u{2318} S")
23//!     .show(&trigger);
24//! # });
25//! ```
26
27use egui::{
28    emath::RectAlign, Color32, CornerRadius, Frame, Margin, Pos2, Rect, Response, Sense, Shape,
29    Stroke, Ui, Vec2, WidgetText,
30};
31
32use crate::theme::Theme;
33
34/// Where the tooltip opens relative to its trigger.
35///
36/// The chosen side is honoured even when there's not enough room on that
37/// side; pick the side with care for triggers near the viewport edge.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub enum TooltipSide {
40    /// Above the trigger; arrow points down. The default.
41    Top,
42    /// Below the trigger; arrow points up.
43    Bottom,
44    /// Left of the trigger; arrow points right.
45    Left,
46    /// Right of the trigger; arrow points left.
47    Right,
48}
49
50impl TooltipSide {
51    fn to_rect_align(self) -> RectAlign {
52        match self {
53            TooltipSide::Top => RectAlign::TOP,
54            TooltipSide::Bottom => RectAlign::BOTTOM,
55            TooltipSide::Left => RectAlign::LEFT,
56            TooltipSide::Right => RectAlign::RIGHT,
57        }
58    }
59}
60
61/// A hover-triggered tooltip attached to a [`Response`].
62///
63/// Construct with the body text and optionally layer on a heading or a
64/// keyboard-shortcut row, then call [`Tooltip::show`] immediately after
65/// rendering the trigger.
66#[derive(Clone, Debug)]
67#[must_use = "Call `.show(&trigger)` to render the tooltip."]
68pub struct Tooltip {
69    body: WidgetText,
70    heading: Option<WidgetText>,
71    shortcut: Option<String>,
72    shortcut_label: String,
73    side: TooltipSide,
74    width: Option<f32>,
75    arrow: bool,
76    gap: f32,
77}
78
79impl Tooltip {
80    /// Create a tooltip with the given body text.
81    ///
82    /// Defaults: anchored above the trigger, no heading, no shortcut row,
83    /// arrow on, ~8 pt gap between trigger and tooltip.
84    pub fn new(body: impl Into<WidgetText>) -> Self {
85        Self {
86            body: body.into(),
87            heading: None,
88            shortcut: None,
89            shortcut_label: "Shortcut".into(),
90            side: TooltipSide::Top,
91            width: None,
92            arrow: true,
93            gap: 8.0,
94        }
95    }
96
97    /// Add a bold heading line above the body.
98    #[inline]
99    pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {
100        self.heading = Some(heading.into());
101        self
102    }
103
104    /// Add a keyboard-shortcut row at the bottom of the tooltip.
105    ///
106    /// The string is split on whitespace and each token is rendered as a
107    /// small monospace chip, so `"\u{2318} S"` renders as two chips.
108    #[inline]
109    pub fn shortcut(mut self, shortcut: impl Into<String>) -> Self {
110        self.shortcut = Some(shortcut.into());
111        self
112    }
113
114    /// Override the leading label on the shortcut row. Default: `"Shortcut"`.
115    #[inline]
116    pub fn shortcut_label(mut self, label: impl Into<String>) -> Self {
117        self.shortcut_label = label.into();
118        self
119    }
120
121    /// Which side of the trigger to anchor on. Default: [`TooltipSide::Top`].
122    #[inline]
123    pub fn side(mut self, side: TooltipSide) -> Self {
124        self.side = side;
125        self
126    }
127
128    /// Fix the tooltip's max content width, in points. Long body text wraps
129    /// at this width. Default: 260.
130    #[inline]
131    pub fn width(mut self, w: f32) -> Self {
132        self.width = Some(w);
133        self
134    }
135
136    /// Toggle the small arrow that points at the trigger. Default: on.
137    #[inline]
138    pub fn arrow(mut self, arrow: bool) -> Self {
139        self.arrow = arrow;
140        self
141    }
142
143    /// Gap between the trigger and the tooltip, in points. Default: 8.
144    #[inline]
145    pub fn gap(mut self, gap: f32) -> Self {
146        self.gap = gap;
147        self
148    }
149
150    /// Render the tooltip attached to `trigger`. Returns `Some(response)`
151    /// while the tooltip is visible, `None` otherwise.
152    pub fn show(self, trigger: &Response) -> Option<Response> {
153        let theme = Theme::current(&trigger.ctx);
154        let p = &theme.palette;
155
156        let frame = Frame::new()
157            .fill(p.card)
158            .stroke(Stroke::new(1.0, p.border))
159            .corner_radius(CornerRadius::same(theme.control_radius as u8))
160            .inner_margin(Margin::symmetric(10, 8));
161
162        let mut tip = egui::Tooltip::for_enabled(trigger);
163        tip.popup = tip
164            .popup
165            .frame(frame)
166            .align(self.side.to_rect_align())
167            .align_alternatives(&[])
168            .gap(self.gap);
169        tip.popup = tip.popup.width(self.width.unwrap_or(260.0));
170
171        let trigger_rect = trigger.rect;
172        let trigger_ctx = trigger.ctx.clone();
173        let arrow = self.arrow;
174        let side = self.side;
175        let theme_for_paint = theme.clone();
176        let heading = self.heading;
177        let body = self.body;
178        let shortcut = self.shortcut;
179        let shortcut_label = self.shortcut_label;
180
181        let inner = tip.show(move |ui| {
182            paint_contents(
183                ui,
184                &theme_for_paint,
185                heading.as_ref(),
186                &body,
187                shortcut.as_deref(),
188                &shortcut_label,
189            );
190        })?;
191
192        if arrow {
193            let actual_side = detect_side(trigger_rect, inner.response.rect, side);
194            paint_arrow(
195                &trigger_ctx,
196                inner.response.layer_id,
197                inner.response.rect,
198                trigger_rect,
199                actual_side,
200                theme.palette.card,
201                theme.palette.border,
202            );
203        }
204
205        Some(inner.response)
206    }
207}
208
209fn paint_contents(
210    ui: &mut Ui,
211    theme: &Theme,
212    heading: Option<&WidgetText>,
213    body: &WidgetText,
214    shortcut: Option<&str>,
215    shortcut_label: &str,
216) {
217    let p = &theme.palette;
218    let t = &theme.typography;
219
220    if let Some(h) = heading {
221        ui.add(
222            egui::Label::new(
223                egui::RichText::new(h.text())
224                    .color(p.text)
225                    .size(t.body)
226                    .strong(),
227            )
228            .wrap_mode(egui::TextWrapMode::Wrap),
229        );
230        ui.add_space(2.0);
231    }
232
233    ui.add(
234        egui::Label::new(
235            egui::RichText::new(body.text())
236                .color(p.text_muted)
237                .size(t.small),
238        )
239        .wrap_mode(egui::TextWrapMode::Wrap),
240    );
241
242    if let Some(sc) = shortcut {
243        ui.add_space(6.0);
244        let avail = ui.available_width();
245        let sep_y = ui.cursor().min.y;
246        ui.painter().line_segment(
247            [
248                Pos2::new(ui.cursor().min.x, sep_y),
249                Pos2::new(ui.cursor().min.x + avail, sep_y),
250            ],
251            Stroke::new(1.0, p.border),
252        );
253        ui.add_space(6.0);
254
255        ui.horizontal(|ui| {
256            ui.spacing_mut().item_spacing.x = 6.0;
257            ui.add(egui::Label::new(
258                egui::RichText::new(shortcut_label)
259                    .color(p.text_faint)
260                    .size(t.small),
261            ));
262            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
263                ui.spacing_mut().item_spacing.x = 2.0;
264                // right_to_left iterates in reverse, so push tokens in reverse
265                // order to render them left-to-right at the right edge.
266                let tokens: Vec<&str> = sc.split_whitespace().collect();
267                for token in tokens.iter().rev() {
268                    add_kbd(ui, token, theme);
269                }
270            });
271        });
272    }
273}
274
275fn add_kbd(ui: &mut Ui, text: &str, theme: &Theme) -> Response {
276    let p = &theme.palette;
277    let font_id = egui::FontId::monospace(11.0);
278    let galley = ui
279        .painter()
280        .layout_no_wrap(text.to_string(), font_id, p.text);
281    let pad_x = 5.0;
282    let width = (galley.size().x + pad_x * 2.0).max(16.0);
283    let height = 18.0;
284    let (rect, response) = ui.allocate_exact_size(Vec2::new(width, height), Sense::hover());
285    if ui.is_rect_visible(rect) {
286        ui.painter().rect(
287            rect,
288            CornerRadius::same(3),
289            p.input_bg,
290            Stroke::new(1.0, p.border),
291            egui::StrokeKind::Inside,
292        );
293        let pos = Pos2::new(
294            rect.center().x - galley.size().x * 0.5,
295            rect.center().y - galley.size().y * 0.5,
296        );
297        ui.painter().galley(pos, galley, p.text);
298    }
299    response
300}
301
302fn detect_side(trigger: Rect, popup: Rect, requested: TooltipSide) -> TooltipSide {
303    match requested {
304        TooltipSide::Top | TooltipSide::Bottom => {
305            if popup.center().y < trigger.center().y {
306                TooltipSide::Top
307            } else {
308                TooltipSide::Bottom
309            }
310        }
311        TooltipSide::Left | TooltipSide::Right => {
312            if popup.center().x < trigger.center().x {
313                TooltipSide::Left
314            } else {
315                TooltipSide::Right
316            }
317        }
318    }
319}
320
321fn paint_arrow(
322    ctx: &egui::Context,
323    layer: egui::LayerId,
324    popup: Rect,
325    trigger: Rect,
326    side: TooltipSide,
327    fill: Color32,
328    border: Color32,
329) {
330    let painter = ctx.layer_painter(layer);
331    let half_base = 6.0;
332    let depth = 6.0;
333    let inset = 10.0;
334
335    let (base_center, perp, base_axis) = match side {
336        TooltipSide::Bottom => {
337            let cx = trigger
338                .center()
339                .x
340                .clamp(popup.min.x + inset, popup.max.x - inset);
341            (
342                Pos2::new(cx, popup.min.y),
343                Vec2::new(0.0, -1.0),
344                Vec2::new(1.0, 0.0),
345            )
346        }
347        TooltipSide::Top => {
348            let cx = trigger
349                .center()
350                .x
351                .clamp(popup.min.x + inset, popup.max.x - inset);
352            (
353                Pos2::new(cx, popup.max.y),
354                Vec2::new(0.0, 1.0),
355                Vec2::new(1.0, 0.0),
356            )
357        }
358        TooltipSide::Right => {
359            let cy = trigger
360                .center()
361                .y
362                .clamp(popup.min.y + inset, popup.max.y - inset);
363            (
364                Pos2::new(popup.min.x, cy),
365                Vec2::new(-1.0, 0.0),
366                Vec2::new(0.0, 1.0),
367            )
368        }
369        TooltipSide::Left => {
370            let cy = trigger
371                .center()
372                .y
373                .clamp(popup.min.y + inset, popup.max.y - inset);
374            (
375                Pos2::new(popup.max.x, cy),
376                Vec2::new(1.0, 0.0),
377                Vec2::new(0.0, 1.0),
378            )
379        }
380    };
381
382    let base_a = base_center + base_axis * half_base;
383    let base_b = base_center - base_axis * half_base;
384    let tip = base_center + perp * depth;
385
386    painter.add(Shape::convex_polygon(
387        vec![base_a, tip, base_b],
388        fill,
389        Stroke::NONE,
390    ));
391    painter.line_segment([base_a, base_b], Stroke::new(1.5, fill));
392    let stroke = Stroke::new(1.0, border);
393    painter.line_segment([base_a, tip], stroke);
394    painter.line_segment([base_b, tip], stroke);
395}