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        match self.children.first() {
206            Some(child) => child.style_themed(theme),
207            None => Style::default(),
208        }
209    }
210
211    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
212        &self.children
213    }
214
215    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
216
217    fn on_click(&self) -> Option<Msg> {
218        None
219    }
220
221    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
222        self.children
223            .get(1)
224            .map(|bubble| (bubble.as_ref(), Placement::Tooltip))
225    }
226
227    /// A tooltip **says** what it says. Without this the bubble is a picture: the label
228    /// is drawn, a sighted reader gets the hint, and a reader who cannot see it gets
229    /// nothing — on a control whose whole reason for having a tooltip is usually that
230    /// its icon does not say what it does.
231    ///
232    /// It goes in as a **label**, which
233    /// [`SemanticsProperties::over`](frus_core::SemanticsProperties::over) joins to the
234    /// child's own on its own line, so a reader hears the hint and the control. The
235    /// reference has a separate `tooltip` property and both platforms have a field for
236    /// it (`AccessibilityNodeInfo.setTooltipText`, `AXHelp`); this framework's
237    /// `SemanticsProperties` does not, and adding it is a change to the platform bridge
238    /// rather than to a widget. See the roadmap.
239    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
240        if self.message.is_empty() || !self.enabled {
241            return None;
242        }
243        Some(frus_core::SemanticsProperties::new(frus_core::Role::None).label(self.message.clone()))
244    }
245}
246
247/// The floating label itself.
248///
249/// A widget rather than a `Container` holding a `Text`, for the reason every themed
250/// panel in this crate is one: the rungs resolve against a `&Theme`, and a tree built by
251/// a builder has none in hand.
252struct Bubble {
253    message: String,
254    background: Option<Color>,
255    text_style: Option<TextStyle>,
256    text_color: Option<Color>,
257    text_align: Option<TextAlign>,
258    padding: Option<Insets>,
259    shape: Option<ShapeBorder>,
260    max_width: Option<f32>,
261}
262
263impl Bubble {
264    /// The label's type, **resolved once** so that the number the box is measured with is
265    /// the number the glyphs are drawn at. Resolving is the single place the reader's
266    /// font setting is applied (milestone 403).
267    fn label_style(&self, theme: Option<&Theme>) -> ResolvedTextStyle {
268        self.text_style
269            .or(theme.and_then(|t| t.widgets.tooltip.text_style))
270            .unwrap_or_else(|| crate::theme::type_scale(theme).body_small)
271            .resolved()
272    }
273
274    /// Where the lines sit in the bubble once the label has wrapped. It travels with the
275    /// width rather than on the style, because an alignment with nothing to align
276    /// against is not a setting — see [`TextBlock`](frus_core::TextBlock).
277    fn align(&self, theme: Option<&Theme>) -> TextAlign {
278        self.text_align
279            .or(theme.and_then(|t| t.widgets.tooltip.text_align))
280            .unwrap_or(TextAlign::Start)
281    }
282
283    fn padding(&self, theme: Option<&Theme>) -> Insets {
284        self.padding
285            .or(theme.and_then(|t| t.widgets.tooltip.padding))
286            .unwrap_or(Insets::new(PAD_Y, PAD_X, PAD_Y, PAD_X))
287    }
288
289    fn max_width(&self, theme: Option<&Theme>) -> f32 {
290        self.max_width
291            .or(theme.and_then(|t| t.widgets.tooltip.max_width))
292            .unwrap_or(MAX_WIDTH)
293    }
294
295    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
296        crate::resolve_shape(
297            self.shape,
298            theme.widgets.tooltip.shape,
299            theme.widgets.tooltip.radius.map(BorderRadius::uniform),
300            ShapeBorder::rounded(RADIUS),
301        )
302    }
303
304    fn sizing(&self, theme: Option<&Theme>) -> Style {
305        let padding = self.padding(theme);
306        let style = self.label_style(theme);
307        let room = (self.max_width(theme) - padding.left - padding.right).max(1.0);
308        let measured = frus_text::measure_wrapped_resolved(&self.message, &style, Some(room));
309        Style {
310            width: Dimension::Length((measured.width + padding.left + padding.right).ceil()),
311            height: Dimension::Length((measured.height + padding.top + padding.bottom).ceil()),
312            ..Default::default()
313        }
314    }
315}
316
317impl<Msg> Widget<Msg> for Bubble {
318    fn style(&self) -> Style {
319        self.sizing(None)
320    }
321
322    fn style_themed(&self, theme: &Theme) -> Style {
323        self.sizing(Some(theme))
324    }
325
326    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
327        &[]
328    }
329
330    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
331        let o = status.opacity;
332        let t = &theme.widgets.tooltip;
333        // The **inverted** surface, which is what a tooltip is everywhere: a label that
334        // does not read as another panel of the interface. The reference builds it by
335        // hand from white and grey with the theme's brightness in a `switch`
336        // (`tooltip.dart:481`); the scheme has had the role for it since it was written.
337        let fill = self
338            .background
339            .or(t.background)
340            .unwrap_or(theme.scheme.inverse_surface);
341        scene.draw_shape(bounds, self.shape_of(theme), fill.fade(o));
342        let padding = self.padding(Some(theme));
343        let style = self.label_style(Some(theme));
344        let ink = self
345            .text_color
346            .or(t.text_color)
347            .unwrap_or(theme.scheme.on_inverse_surface);
348        scene.text_block(
349            Point::new(bounds.x + padding.left, bounds.y + padding.top),
350            self.message.clone(),
351            &style,
352            ink.fade(o),
353            frus_core::TextBlock {
354                width: Some((bounds.width - padding.left - padding.right).max(0.0)),
355                soft_wrap: true,
356                align: self.align(Some(theme)),
357            },
358        );
359    }
360
361    fn on_click(&self) -> Option<Msg> {
362        None
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::{build_ui, Container, Runtime, Size, Text};
370    use frus_core::Primitive;
371
372    fn child() -> Container<()> {
373        Container::<()>::new().width(40.0).height(20.0)
374    }
375
376    /// **The framework had the place a tooltip goes and no tooltip.**
377    /// `Placement::Tooltip` positions a bubble above its anchor, flips it below when
378    /// there is no room, nudges it inside a window edge and shows it only while the
379    /// anchor is hovered — and nothing in the crate used it.
380    #[test]
381    fn a_tooltip_floats_a_bubble_over_its_child() {
382        let tip = Tooltip::<()>::new("Delete this row").child(child());
383        assert!(Widget::<()>::overlay(&tip).is_some());
384        assert_eq!(
385            Widget::<()>::overlay(&tip).map(|(_, placement)| placement),
386            Some(Placement::Tooltip)
387        );
388        assert_eq!(Widget::<()>::children(&tip).len(), 2);
389    }
390
391    /// **Nothing to say costs nothing.** An empty message, or a disabled tooltip, is the
392    /// child and no overlay at all — which is what lets a caller write
393    /// `Tooltip::new(hint)` where `hint` may be empty without branching.
394    #[test]
395    fn an_empty_message_is_no_tooltip() {
396        let empty = Tooltip::<()>::new("").child(child());
397        assert!(Widget::<()>::overlay(&empty).is_none());
398        assert_eq!(Widget::<()>::children(&empty).len(), 1);
399
400        let off = Tooltip::<()>::new("Something")
401            .child(child())
402            .enabled(false);
403        assert!(Widget::<()>::overlay(&off).is_none());
404    }
405
406    /// **A tooltip is not a box.** It is whatever its child is, with something floating
407    /// over it — so it forwards the child's own layout rather than replacing it with a
408    /// default. Milestone 425's rule, which every transparent wrapper here has to keep.
409    #[test]
410    fn a_tooltip_forwards_its_child_s_layout() {
411        let theme = Theme::default();
412        let bare = child().width(123.0);
413        let wrapped = Tooltip::<()>::new("hint").child(child().width(123.0));
414        assert_eq!(
415            Widget::<()>::style_themed(&wrapped, &theme).width,
416            Widget::<()>::style_themed(&bare, &theme).width
417        );
418    }
419
420    /// **A tooltip says what it says.** Without semantics the bubble is a picture: a
421    /// sighted reader gets the hint and a reader who cannot see it gets nothing, on a
422    /// control that usually has a tooltip *because* its icon does not say what it does.
423    #[test]
424    fn a_tooltip_is_spoken() {
425        let tip = Tooltip::<()>::new("Delete this row").child(child());
426        let semantics = Widget::<()>::semantics(&tip).expect("a tooltip speaks");
427        assert_eq!(semantics.label.as_deref(), Some("Delete this row"));
428        assert!(semantics.is_meaningful(), "so it reaches the tree at all");
429        assert!(Widget::<()>::semantics(&Tooltip::<()>::new("").child(child())).is_none());
430    }
431
432    fn bubble_of(tip: &Tooltip<()>, theme: &Theme) -> (frus_core::Color, BorderRadius) {
433        let (bubble, _) = Widget::<()>::overlay(tip).expect("a bubble");
434        let mut scene = Scene::new();
435        bubble.paint(
436            Rect::new(0.0, 0.0, 100.0, 24.0),
437            Status::default(),
438            theme,
439            &mut scene,
440        );
441        scene
442            .primitives()
443            .iter()
444            .find_map(|p| match p {
445                Primitive::Rect { color, radius, .. } => Some((*color, *radius)),
446                _ => None,
447            })
448            .expect("a surface")
449    }
450
451    /// The bubble is the **inverted** surface — the role the scheme has had all along,
452    /// where the reference builds the same thing out of white and grey by hand
453    /// (`tooltip.dart:481`) — and every part of it answers to a theme and to a caller
454    /// over the theme.
455    #[test]
456    fn a_bubble_is_the_inverted_surface_and_answers_to_a_theme() {
457        let theme = Theme::default();
458        let plain = Tooltip::<()>::new("hint").child(child());
459        assert_eq!(
460            bubble_of(&plain, &theme),
461            (theme.scheme.inverse_surface, BorderRadius::uniform(RADIUS))
462        );
463
464        let mut themed = Theme::default();
465        themed.widgets.tooltip.background = Some(Color::rgb(0.2, 0.4, 0.6));
466        themed.widgets.tooltip.radius = Some(11.0);
467        assert_eq!(
468            bubble_of(&plain, &themed),
469            (Color::rgb(0.2, 0.4, 0.6), BorderRadius::uniform(11.0))
470        );
471
472        let told = Tooltip::<()>::new("hint")
473            .child(child())
474            .background(Color::rgb(0.9, 0.1, 0.1))
475            .radius(3.0);
476        assert_eq!(
477            bubble_of(&told, &themed),
478            (Color::rgb(0.9, 0.1, 0.1), BorderRadius::uniform(3.0))
479        );
480    }
481
482    /// **A bubble stops growing.** A hint long enough to fill a desktop window is not a
483    /// tooltip any more, so the label wraps at `max_width` — the one number here the
484    /// reference does not have, since it constrains a tooltip by the window instead.
485    #[test]
486    fn a_long_hint_wraps_rather_than_crossing_the_window() {
487        let theme = Theme::default();
488        let long = "This row cannot be deleted while it is the last one left in the table, \
489                    because a table with no rows has nothing to describe its columns.";
490        let box_of = |width: Option<f32>| {
491            let mut tip = Tooltip::<()>::new(long).child(child());
492            if let Some(width) = width {
493                tip = tip.max_width(width);
494            }
495            let (bubble, _) = Widget::<()>::overlay(&tip).expect("a bubble");
496            let style = bubble.style_themed(&theme);
497            match (style.width, style.height) {
498                (Dimension::Length(w), Dimension::Length(h)) => (w, h),
499                other => panic!("a measured box, not {other:?}"),
500            }
501        };
502
503        // The box is the widest line plus its padding, so it lands at or just under the
504        // allowance rather than exactly on it — the text breaks at a word.
505        let (wide_w, wide_h) = box_of(None);
506        assert!(
507            wide_w <= MAX_WIDTH && wide_w > MAX_WIDTH - 20.0,
508            "it filled the allowance and did not cross it: {wide_w}"
509        );
510        assert!(wide_h > 30.0, "it wrapped onto several lines: {wide_h}");
511
512        // And a caller can say how wide is wide enough. Narrower means taller: the same
513        // words in a narrower box.
514        let (narrow_w, narrow_h) = box_of(Some(120.0));
515        assert!(narrow_w <= 120.0, "{narrow_w}");
516        assert!(narrow_h > wide_h, "{narrow_h} vs {wide_h}");
517    }
518
519    /// The bubble shows **only while the child is hovered**, which is the overlay
520    /// system's own rule for this placement and the reason a tooltip needs no state of
521    /// its own. With nothing hovered, the frame has no bubble in it.
522    #[test]
523    fn a_bubble_waits_for_the_pointer() {
524        let tip = Tooltip::<()>::new("hint").child(child());
525        let ui = build_ui(
526            &tip,
527            Size::new(300.0, 200.0),
528            &Runtime::default(),
529            &Theme::default(),
530        );
531        let painted = ui.scene().primitives().len();
532        assert_eq!(painted, 0, "nothing is hovered, so nothing floats");
533
534        // The same tree with something to draw, to prove the assertion above is not
535        // passing because the tooltip draws nothing at all.
536        let with_text = Tooltip::<()>::new("hint").child(Text::new("x"));
537        let ui = build_ui(
538            &with_text,
539            Size::new(300.0, 200.0),
540            &Runtime::default(),
541            &Theme::default(),
542        );
543        assert!(!ui.scene().primitives().is_empty(), "the child is drawn");
544    }
545}