iced_cube/overlay/
tooltip.rs1use 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#[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
44pub const GAP: f32 = 6.0;
46
47pub 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
65pub 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 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
105pub 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}