Skip to main content

iced_cube/overlay/
tooltip.rs

1//! A small label shown while the pointer rests on an element.
2//!
3//! A finger shows it while held. Once the finger lifts, the bubble stays
4//! closed until a mouse is used, so a tap on a phone does not leave it open.
5
6use iced::time::Duration;
7use iced::widget::{self, container, text};
8use iced::{Background, Border, Element};
9
10use crate::after_touch::after_touch;
11use crate::theme::{Tokens, radius, space, text_size};
12
13/// Where the bubble appears relative to its element.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum Position {
16    #[default]
17    Top,
18    Bottom,
19    Left,
20    Right,
21    FollowCursor,
22}
23
24impl Position {
25    pub const ALL: [Position; 5] = [
26        Position::Top,
27        Position::Bottom,
28        Position::Left,
29        Position::Right,
30        Position::FollowCursor,
31    ];
32
33    fn to_iced(self) -> widget::tooltip::Position {
34        match self {
35            Position::Top => widget::tooltip::Position::Top,
36            Position::Bottom => widget::tooltip::Position::Bottom,
37            Position::Left => widget::tooltip::Position::Left,
38            Position::Right => widget::tooltip::Position::Right,
39            Position::FollowCursor => widget::tooltip::Position::FollowCursor,
40        }
41    }
42}
43
44/// Gap between the element and the bubble, in logical pixels.
45pub const GAP: f32 = 6.0;
46
47/// A tooltip builder. Convert it into an [`Element`] to render.
48pub struct Tooltip<'a, Message> {
49    content: Element<'a, Message>,
50    label: text::Fragment<'a>,
51    position: Position,
52    delay: Duration,
53}
54
55impl<Message> std::fmt::Debug for Tooltip<'_, Message> {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.debug_struct("Tooltip")
58            .field("label", &self.label)
59            .field("position", &self.position)
60            .field("delay", &self.delay)
61            .finish_non_exhaustive()
62    }
63}
64
65/// Shows `label` in a bubble while the pointer is over `content`.
66pub fn tooltip<'a, Message>(
67    content: impl Into<Element<'a, Message>>,
68    label: impl text::IntoFragment<'a>,
69) -> Tooltip<'a, Message> {
70    Tooltip {
71        content: content.into(),
72        label: label.into_fragment(),
73        position: Position::default(),
74        delay: Duration::ZERO,
75    }
76}
77
78impl<'a, Message> Tooltip<'a, Message> {
79    pub fn position(mut self, position: Position) -> Self {
80        self.position = position;
81        self
82    }
83
84    /// How long the pointer has to rest before the bubble appears.
85    pub fn delay(mut self, delay: Duration) -> Self {
86        self.delay = delay;
87        self
88    }
89}
90
91impl<'a, Message: 'a> From<Tooltip<'a, Message>> for Element<'a, Message> {
92    fn from(tooltip: Tooltip<'a, Message>) -> Self {
93        let bubble = container(text(tooltip.label).size(text_size::XS))
94            .padding([space::XS + 2.0, space::MD])
95            .style(|theme| style(&Tokens::of(theme)));
96
97        after_touch(
98            widget::tooltip(tooltip.content, bubble, tooltip.position.to_iced())
99                .gap(GAP)
100                .delay(tooltip.delay),
101        )
102    }
103}
104
105/// The bubble style: inverted colours so it stands out from the page.
106pub fn style(tokens: &Tokens) -> container::Style {
107    container::Style {
108        background: Some(Background::Color(tokens.primary)),
109        text_color: Some(tokens.primary_foreground),
110        border: Border {
111            radius: radius::MD.into(),
112            ..Border::default()
113        },
114        ..container::Style::default()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::theme::{dark, light};
122
123    #[test]
124    fn defaults_to_top_without_delay() {
125        let t: Tooltip<'_, ()> = tooltip(text("Hover"), "Hint");
126        assert_eq!(t.position, Position::Top);
127        assert_eq!(t.delay, Duration::ZERO);
128    }
129
130    #[test]
131    fn every_position_maps_to_a_distinct_iced_position() {
132        let mapped = Position::ALL.map(Position::to_iced);
133        for (i, a) in mapped.iter().enumerate() {
134            assert!(mapped[i + 1..].iter().all(|b| a != b));
135        }
136    }
137
138    #[test]
139    fn bubble_inverts_the_page_in_both_themes() {
140        for theme in [light(), dark()] {
141            let tokens = Tokens::of(&theme);
142            let style = style(&tokens);
143            assert_eq!(style.background, Some(Background::Color(tokens.primary)));
144            assert_eq!(style.text_color, Some(tokens.primary_foreground));
145            assert_ne!(tokens.primary, tokens.background);
146        }
147    }
148}