Skip to main content

iced_cube/overlay/
tooltip.rs

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