iced_cube/overlay/
tooltip.rs1use 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#[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
40pub const GAP: f32 = 6.0;
42
43pub 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
61pub 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 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
100pub 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}