Skip to main content

frus_widgets/
tooltip.rs

1//! [`Tooltip`]: a short label that appears while a pointer rests on something.
2//!
3//! The framework has had the *place a tooltip goes* since the overlay system was built
4//! — [`Placement::Tooltip`] positions a bubble above its anchor, flips it below when
5//! there is no room above, nudges it back inside a window edge, and shows it only while
6//! the anchor is hovered — and **nothing used it**. This is the widget that does.
7
8use frus_core::{
9    BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextAlign,
10    TextStyle,
11};
12use frus_layout::{Dimension, Style};
13
14use crate::interaction::Status;
15use crate::portal::Placement;
16use crate::theme::Theme;
17use crate::widget::Widget;
18
19/// The room kept round the label. The reference gives a tooltip sixteen pixels either
20/// side on a touch platform and eight on a pointer one (`tooltip.dart`); this takes the
21/// pointer pair, since a bubble that appears on hover is a pointer's idiom, and leaves
22/// both to [`Tooltip::padding`].
23const PAD_X: f32 = 8.0;
24const PAD_Y: f32 = 4.0;
25
26/// How wide a bubble is allowed to get before its text wraps. Not a number the reference
27/// has — it constrains a tooltip by the window — but a bubble as wide as a desktop
28/// window is not a tooltip, it is a paragraph that appeared under the mouse.
29const MAX_WIDTH: f32 = 320.0;
30
31/// The bubble's corner (`tooltip.dart:496`). Small, and deliberately not
32/// `Theme::radius`: a tooltip is not a surface of the interface, it is a label on top of
33/// one, and it reads as attached to what it describes rather than as another panel.
34const RADIUS: f32 = 4.0;
35
36/// **A short label that appears while a pointer rests on something.**
37///
38/// ```ignore
39/// Tooltip::new("Delete this row").child(IconButton::new(Icons::DELETE).on_press(Msg::Delete))
40/// ```
41///
42/// It wraps a child, shows nothing until the child is hovered, and then floats a bubble
43/// above it — below, if there is no room above. An empty message shows nothing at all
44/// and costs nothing, which is what lets a caller write
45/// `Tooltip::new(maybe_hint).child(..)` without branching.
46///
47/// # What this cannot do yet
48///
49/// The reference's `waitDuration`, `showDuration`, `exitDuration` and `triggerMode` are
50/// **not** here. All four are about *time*: how long a pointer must rest before the
51/// bubble appears, how long it stays, and whether a long press brings it up on a touch
52/// screen. The framework shows the bubble while the anchor is hovered and hides it when
53/// the pointer leaves, which is the whole of what the overlay system can express today.
54/// See the roadmap.
55pub struct Tooltip<Msg = crate::callback::Callback> {
56    /// `[child]`, or `[child, bubble]` when there is something to show.
57    children: Vec<Box<dyn Widget<Msg>>>,
58    message: String,
59    enabled: bool,
60    background: Option<Color>,
61    text_style: Option<TextStyle>,
62    text_color: Option<Color>,
63    text_align: Option<TextAlign>,
64    padding: Option<Insets>,
65    shape: Option<ShapeBorder>,
66    max_width: Option<f32>,
67}
68
69impl<Msg: Clone + 'static> Tooltip<Msg> {
70    /// Creates a tooltip carrying `message`. Add the thing it describes with
71    /// [`child`](Self::child).
72    ///
73    /// An **empty** message is not an error: the tooltip becomes the child and nothing
74    /// else, so a caller with an optional hint does not have to branch.
75    pub fn new(message: impl Into<String>) -> Self {
76        Self {
77            children: Vec::new(),
78            message: message.into(),
79            enabled: true,
80            background: None,
81            text_style: None,
82            text_color: None,
83            text_align: None,
84            padding: None,
85            shape: None,
86            max_width: None,
87        }
88    }
89
90    /// The widget the tooltip describes.
91    #[must_use]
92    pub fn child(mut self, child: impl Widget<Msg> + 'static) -> Self {
93        if self.children.is_empty() {
94            self.children.push(Box::new(child));
95        } else {
96            self.children[0] = Box::new(child);
97        }
98        self.rebuild();
99        self
100    }
101
102    /// Whether the tooltip shows at all. A disabled one is its child and nothing more.
103    #[must_use]
104    pub fn enabled(mut self, enabled: bool) -> Self {
105        self.enabled = enabled;
106        self.rebuild();
107        self
108    }
109
110    /// **The bubble's surface**, over the theme's and `inverse_surface`.
111    #[must_use]
112    pub fn background(mut self, color: Color) -> Self {
113        self.background = Some(color);
114        self.rebuild();
115        self
116    }
117
118    /// The label's type, over the theme's and `body_small`.
119    #[must_use]
120    pub fn text_style(mut self, style: TextStyle) -> Self {
121        self.text_style = Some(style);
122        self.rebuild();
123        self
124    }
125
126    /// The label's colour, over the theme's and `on_inverse_surface`.
127    #[must_use]
128    pub fn text_color(mut self, color: Color) -> Self {
129        self.text_color = Some(color);
130        self.rebuild();
131        self
132    }
133
134    /// How the label sits in the bubble when it has wrapped onto more than one line.
135    #[must_use]
136    pub fn text_align(mut self, align: TextAlign) -> Self {
137        self.text_align = Some(align);
138        self.rebuild();
139        self
140    }
141
142    /// The room kept round the label.
143    #[must_use]
144    pub fn padding(mut self, padding: Insets) -> Self {
145        self.padding = Some(padding);
146        self.rebuild();
147        self
148    }
149
150    /// **What shape the bubble is**, over the theme's and the framework's four.
151    #[must_use]
152    pub fn shape(mut self, shape: ShapeBorder) -> Self {
153        self.shape = Some(shape);
154        self.rebuild();
155        self
156    }
157
158    /// The shorthand for a rounded rectangle.
159    #[must_use]
160    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
161        self.shape(ShapeBorder::rounded(radius.into()))
162    }
163
164    /// **How wide the bubble may get** before the label wraps.
165    #[must_use]
166    pub fn max_width(mut self, width: f32) -> Self {
167        self.max_width = Some(width);
168        self.rebuild();
169        self
170    }
171
172    /// (Re)builds the bubble (child 1).
173    ///
174    /// Every builder calls this, so **the order they are written in does not matter** —
175    /// the trap milestone 458 found in [`BottomSheet`](crate::BottomSheet), where the
176    /// panel is built by one particular method and anything said after it is dropped.
177    fn rebuild(&mut self) {
178        self.children.truncate(1);
179        if self.children.is_empty() || self.message.is_empty() || !self.enabled {
180            return;
181        }
182        self.children.push(Box::new(Bubble {
183            message: self.message.clone(),
184            background: self.background,
185            text_style: self.text_style,
186            text_color: self.text_color,
187            text_align: self.text_align,
188            padding: self.padding,
189            shape: self.shape,
190            max_width: self.max_width,
191        }));
192    }
193}
194
195impl<Msg: Clone> Widget<Msg> for Tooltip<Msg> {
196    fn style(&self) -> Style {
197        Style::default()
198    }
199
200    /// A tooltip is not a box: it is whatever its child is, with something floating over
201    /// it. It forwards the child's structure the way every transparent wrapper in this
202    /// crate has to (milestone 425) — a tooltip round a row must not turn the row into a
203    /// column.
204    fn style_themed(&self, theme: &Theme) -> Style {
205        // Everything but the padding, which is the child's own: the child is a node
206        // inside this one, and a padding said twice squeezes it by its own room — a
207        // button's 24 px either side, since buttons lay out their content (milestone 632).
208        match self.children.first() {
209            Some(child) => Style {
210                padding: frus_core::Insets::ZERO,
211                ..child.style_themed(theme)
212            },
213            None => Style::default(),
214        }
215    }
216
217    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
218        &self.children
219    }
220
221    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
222
223    fn on_click(&self) -> Option<Msg> {
224        None
225    }
226
227    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
228        self.children
229            .get(1)
230            .map(|bubble| (bubble.as_ref(), Placement::Tooltip))
231    }
232
233    /// A tooltip **says** what it says. Without this the bubble is a picture: the label
234    /// is drawn, a sighted reader gets the hint, and a reader who cannot see it gets
235    /// nothing — on a control whose whole reason for having a tooltip is usually that
236    /// its icon does not say what it does.
237    ///
238    /// It goes in as a **label**, which
239    /// [`SemanticsProperties::over`](frus_core::SemanticsProperties::over) joins to the
240    /// child's own on its own line, so a reader hears the hint and the control. The
241    /// reference has a separate `tooltip` property and both platforms have a field for
242    /// it (`AccessibilityNodeInfo.setTooltipText`, `AXHelp`); this framework's
243    /// `SemanticsProperties` does not, and adding it is a change to the platform bridge
244    /// rather than to a widget. See the roadmap.
245    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
246        if self.message.is_empty() || !self.enabled {
247            return None;
248        }
249        Some(frus_core::SemanticsProperties::new(frus_core::Role::None).label(self.message.clone()))
250    }
251}
252
253/// The floating label itself.
254///
255/// A widget rather than a `Container` holding a `Text`, for the reason every themed
256/// panel in this crate is one: the rungs resolve against a `&Theme`, and a tree built by
257/// a builder has none in hand.
258struct Bubble {
259    message: String,
260    background: Option<Color>,
261    text_style: Option<TextStyle>,
262    text_color: Option<Color>,
263    text_align: Option<TextAlign>,
264    padding: Option<Insets>,
265    shape: Option<ShapeBorder>,
266    max_width: Option<f32>,
267}
268
269impl Bubble {
270    /// The label's type, **resolved once** so that the number the box is measured with is
271    /// the number the glyphs are drawn at. Resolving is the single place the reader's
272    /// font setting is applied (milestone 403).
273    fn label_style(&self, theme: Option<&Theme>) -> ResolvedTextStyle {
274        self.text_style
275            .or(theme.and_then(|t| t.widgets.tooltip.text_style))
276            .unwrap_or_else(|| crate::theme::type_scale(theme).body_small)
277            .resolved()
278    }
279
280    /// Where the lines sit in the bubble once the label has wrapped. It travels with the
281    /// width rather than on the style, because an alignment with nothing to align
282    /// against is not a setting — see [`TextBlock`](frus_core::TextBlock).
283    fn align(&self, theme: Option<&Theme>) -> TextAlign {
284        self.text_align
285            .or(theme.and_then(|t| t.widgets.tooltip.text_align))
286            .unwrap_or(TextAlign::Start)
287    }
288
289    fn padding(&self, theme: Option<&Theme>) -> Insets {
290        self.padding
291            .or(theme.and_then(|t| t.widgets.tooltip.padding))
292            .unwrap_or(Insets::new(PAD_Y, PAD_X, PAD_Y, PAD_X))
293    }
294
295    fn max_width(&self, theme: Option<&Theme>) -> f32 {
296        self.max_width
297            .or(theme.and_then(|t| t.widgets.tooltip.max_width))
298            .unwrap_or(MAX_WIDTH)
299    }
300
301    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
302        crate::resolve_shape(
303            self.shape,
304            theme.widgets.tooltip.shape,
305            theme.widgets.tooltip.radius.map(BorderRadius::uniform),
306            ShapeBorder::rounded(RADIUS),
307        )
308    }
309
310    fn sizing(&self, theme: Option<&Theme>) -> Style {
311        let padding = self.padding(theme);
312        let style = self.label_style(theme);
313        let room = (self.max_width(theme) - padding.left - padding.right).max(1.0);
314        let measured = frus_text::measure_wrapped_resolved(&self.message, &style, Some(room));
315        Style {
316            width: Dimension::Length((measured.width + padding.left + padding.right).ceil()),
317            height: Dimension::Length((measured.height + padding.top + padding.bottom).ceil()),
318            ..Default::default()
319        }
320    }
321}
322
323impl<Msg> Widget<Msg> for Bubble {
324    fn style(&self) -> Style {
325        self.sizing(None)
326    }
327
328    fn style_themed(&self, theme: &Theme) -> Style {
329        self.sizing(Some(theme))
330    }
331
332    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
333        &[]
334    }
335
336    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
337        let o = status.opacity;
338        let t = &theme.widgets.tooltip;
339        // The **inverted** surface, which is what a tooltip is everywhere: a label that
340        // does not read as another panel of the interface. The reference builds it by
341        // hand from white and grey with the theme's brightness in a `switch`
342        // (`tooltip.dart:481`); the scheme has had the role for it since it was written.
343        let fill = self
344            .background
345            .or(t.background)
346            .unwrap_or(theme.scheme.inverse_surface);
347        scene.draw_shape(bounds, self.shape_of(theme), fill.fade(o));
348        let padding = self.padding(Some(theme));
349        let style = self.label_style(Some(theme));
350        let ink = self
351            .text_color
352            .or(t.text_color)
353            .unwrap_or(theme.scheme.on_inverse_surface);
354        scene.text_block(
355            Point::new(bounds.x + padding.left, bounds.y + padding.top),
356            self.message.clone(),
357            &style,
358            ink.fade(o),
359            frus_core::TextBlock {
360                width: Some((bounds.width - padding.left - padding.right).max(0.0)),
361                soft_wrap: true,
362                align: self.align(Some(theme)),
363            },
364        );
365    }
366
367    fn on_click(&self) -> Option<Msg> {
368        None
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use crate::{build_ui, Container, Runtime, Size, Text};
376    use frus_core::Primitive;
377
378    fn child() -> Container<()> {
379        Container::<()>::new().width(40.0).height(20.0)
380    }
381
382    /// **The framework had the place a tooltip goes and no tooltip.**
383    /// `Placement::Tooltip` positions a bubble above its anchor, flips it below when
384    /// there is no room, nudges it inside a window edge and shows it only while the
385    /// anchor is hovered — and nothing in the crate used it.
386    #[test]
387    fn a_tooltip_floats_a_bubble_over_its_child() {
388        let tip = Tooltip::<()>::new("Delete this row").child(child());
389        assert!(Widget::<()>::overlay(&tip).is_some());
390        assert_eq!(
391            Widget::<()>::overlay(&tip).map(|(_, placement)| placement),
392            Some(Placement::Tooltip)
393        );
394        assert_eq!(Widget::<()>::children(&tip).len(), 2);
395    }
396
397    /// **Nothing to say costs nothing.** An empty message, or a disabled tooltip, is the
398    /// child and no overlay at all — which is what lets a caller write
399    /// `Tooltip::new(hint)` where `hint` may be empty without branching.
400    #[test]
401    fn an_empty_message_is_no_tooltip() {
402        let empty = Tooltip::<()>::new("").child(child());
403        assert!(Widget::<()>::overlay(&empty).is_none());
404        assert_eq!(Widget::<()>::children(&empty).len(), 1);
405
406        let off = Tooltip::<()>::new("Something")
407            .child(child())
408            .enabled(false);
409        assert!(Widget::<()>::overlay(&off).is_none());
410    }
411
412    /// **A tooltip is not a box.** It is whatever its child is, with something floating
413    /// over it — so it forwards the child's own layout rather than replacing it with a
414    /// default. Milestone 425's rule, which every transparent wrapper here has to keep.
415    #[test]
416    fn a_tooltip_forwards_its_child_s_layout() {
417        let theme = Theme::default();
418        let bare = child().width(123.0);
419        let wrapped = Tooltip::<()>::new("hint").child(child().width(123.0));
420        assert_eq!(
421            Widget::<()>::style_themed(&wrapped, &theme).width,
422            Widget::<()>::style_themed(&bare, &theme).width
423        );
424    }
425
426    /// **A tooltip says what it says.** Without semantics the bubble is a picture: a
427    /// sighted reader gets the hint and a reader who cannot see it gets nothing, on a
428    /// control that usually has a tooltip *because* its icon does not say what it does.
429    #[test]
430    fn a_tooltip_is_spoken() {
431        let tip = Tooltip::<()>::new("Delete this row").child(child());
432        let semantics = Widget::<()>::semantics(&tip).expect("a tooltip speaks");
433        assert_eq!(semantics.label.as_deref(), Some("Delete this row"));
434        assert!(semantics.is_meaningful(), "so it reaches the tree at all");
435        assert!(Widget::<()>::semantics(&Tooltip::<()>::new("").child(child())).is_none());
436    }
437
438    fn bubble_of(tip: &Tooltip<()>, theme: &Theme) -> (frus_core::Color, BorderRadius) {
439        let (bubble, _) = Widget::<()>::overlay(tip).expect("a bubble");
440        let mut scene = Scene::new();
441        bubble.paint(
442            Rect::new(0.0, 0.0, 100.0, 24.0),
443            Status::default(),
444            theme,
445            &mut scene,
446        );
447        scene
448            .primitives()
449            .iter()
450            .find_map(|p| match p {
451                Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
452                _ => None,
453            })
454            .expect("a surface")
455    }
456
457    /// The bubble is the **inverted** surface — the role the scheme has had all along,
458    /// where the reference builds the same thing out of white and grey by hand
459    /// (`tooltip.dart:481`) — and every part of it answers to a theme and to a caller
460    /// over the theme.
461    #[test]
462    fn a_bubble_is_the_inverted_surface_and_answers_to_a_theme() {
463        let theme = Theme::default();
464        let plain = Tooltip::<()>::new("hint").child(child());
465        assert_eq!(
466            bubble_of(&plain, &theme),
467            (theme.scheme.inverse_surface, BorderRadius::uniform(RADIUS))
468        );
469
470        let mut themed = Theme::default();
471        themed.widgets.tooltip.background = Some(Color::rgb(0.2, 0.4, 0.6));
472        themed.widgets.tooltip.radius = Some(11.0);
473        assert_eq!(
474            bubble_of(&plain, &themed),
475            (Color::rgb(0.2, 0.4, 0.6), BorderRadius::uniform(11.0))
476        );
477
478        let told = Tooltip::<()>::new("hint")
479            .child(child())
480            .background(Color::rgb(0.9, 0.1, 0.1))
481            .radius(3.0);
482        assert_eq!(
483            bubble_of(&told, &themed),
484            (Color::rgb(0.9, 0.1, 0.1), BorderRadius::uniform(3.0))
485        );
486    }
487
488    /// **A bubble stops growing.** A hint long enough to fill a desktop window is not a
489    /// tooltip any more, so the label wraps at `max_width` — the one number here the
490    /// reference does not have, since it constrains a tooltip by the window instead.
491    #[test]
492    fn a_long_hint_wraps_rather_than_crossing_the_window() {
493        let theme = Theme::default();
494        let long = "This row cannot be deleted while it is the last one left in the table, \
495                    because a table with no rows has nothing to describe its columns.";
496        let box_of = |width: Option<f32>| {
497            let mut tip = Tooltip::<()>::new(long).child(child());
498            if let Some(width) = width {
499                tip = tip.max_width(width);
500            }
501            let (bubble, _) = Widget::<()>::overlay(&tip).expect("a bubble");
502            let style = bubble.style_themed(&theme);
503            match (style.width, style.height) {
504                (Dimension::Length(w), Dimension::Length(h)) => (w, h),
505                other => panic!("a measured box, not {other:?}"),
506            }
507        };
508
509        // The box is the widest line plus its padding, so it lands at or just under the
510        // allowance rather than exactly on it — the text breaks at a word.
511        let (wide_w, wide_h) = box_of(None);
512        assert!(
513            wide_w <= MAX_WIDTH && wide_w > MAX_WIDTH - 20.0,
514            "it filled the allowance and did not cross it: {wide_w}"
515        );
516        assert!(wide_h > 30.0, "it wrapped onto several lines: {wide_h}");
517
518        // And a caller can say how wide is wide enough. Narrower means taller: the same
519        // words in a narrower box.
520        let (narrow_w, narrow_h) = box_of(Some(120.0));
521        assert!(narrow_w <= 120.0, "{narrow_w}");
522        assert!(narrow_h > wide_h, "{narrow_h} vs {wide_h}");
523    }
524
525    /// The bubble shows **only while the child is hovered**, which is the overlay
526    /// system's own rule for this placement and the reason a tooltip needs no state of
527    /// its own. With nothing hovered, the frame has no bubble in it.
528    #[test]
529    fn a_bubble_waits_for_the_pointer() {
530        let tip = Tooltip::<()>::new("hint").child(child());
531        let ui = build_ui(
532            &tip,
533            Size::new(300.0, 200.0),
534            &Runtime::default(),
535            &Theme::default(),
536        );
537        let painted = ui.scene().primitives().len();
538        assert_eq!(painted, 0, "nothing is hovered, so nothing floats");
539
540        // The same tree with something to draw, to prove the assertion above is not
541        // passing because the tooltip draws nothing at all.
542        let with_text = Tooltip::<()>::new("hint").child(Text::new("x"));
543        let ui = build_ui(
544            &with_text,
545            Size::new(300.0, 200.0),
546            &Runtime::default(),
547            &Theme::default(),
548        );
549        assert!(!ui.scene().primitives().is_empty(), "the child is drawn");
550    }
551}