1use std::time::Duration;
4
5use super::placement::{self, Placement};
6use crate::geometry::{Rect, Size};
7use crate::motion::Easing;
8use crate::style::CellStyle;
9use crate::text;
10use crate::widget::{Axis, Container, Flex, Length, MeasureCx, Node, PaintCx, Widget};
11
12pub struct Tooltip<Msg> {
45 text: String,
46 placement: Placement,
47 on_focus: bool,
48 body: Vec<Node<Msg>>,
49}
50
51#[derive(Debug, Default)]
52struct TooltipMemory {
53 hovered_since: Option<Duration>,
54 shown_since: Option<Duration>,
55}
56
57impl<Msg: 'static> Tooltip<Msg> {
58 #[must_use]
61 pub fn new(text: impl Into<String>) -> Self {
62 Self {
63 text: text.into(),
64 placement: Placement::Below,
65 on_focus: false,
66 body: vec![Node::new(Flex::new(Axis::Column, Vec::new()), 0)],
67 }
68 }
69
70 #[must_use]
72 pub fn placement(mut self, placement: Placement) -> Self {
73 self.placement = placement;
74 self
75 }
76
77 #[must_use]
79 pub fn on_focus(mut self, on_focus: bool) -> Self {
80 self.on_focus = on_focus;
81 self
82 }
83}
84
85impl<Msg: 'static> Container<Msg> for Tooltip<Msg> {
86 fn set_children(&mut self, children: Vec<Node<Msg>>) {
87 let mut column = Node::new(Flex::new(Axis::Column, children), 0);
88 column.layout.width = Length::Fill(1);
89 column.layout.height = Length::Fill(1);
90 self.body = vec![column];
91 }
92}
93
94impl<Msg: 'static> Widget<Msg> for Tooltip<Msg> {
95 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
96 self.body.first().map_or(Size::default(), |body| cx.measure_child(body, available))
97 }
98
99 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
100 cx.register_hit(area);
102 if let Some(body) = self.body.first() {
103 cx.paint_child(body, area);
104 }
105 let now = cx.now();
106 let hovered = cx.pointer_within().is_some();
107 let focused = self.on_focus && cx.has_focus_within();
108 let delay = cx.env().theme().motion().hover_delay;
109 let memory = cx.memory::<TooltipMemory>();
110 memory.hovered_since = if hovered { Some(memory.hovered_since.unwrap_or(now)) } else { None };
111 let due = memory.hovered_since.map(|since| since + delay);
112 let visible = focused || due.is_some_and(|due| now >= due);
113 memory.shown_since = if visible { Some(memory.shown_since.unwrap_or(now)) } else { None };
114 if visible {
115 cx.request_overlay(area);
116 } else if let Some(due) = due {
117 cx.request_frame_in(due.saturating_sub(now));
118 }
119 }
120
121 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
122 let style = cx.style("tooltip", None, &[]);
123 let padding = style.padding();
124 let text_style = style.text();
125 let background = text_style.bg.unwrap_or_else(|| cx.color("overlay"));
126 let foreground = text_style.fg.unwrap_or_else(|| cx.color("text"));
127 let screen = cx.clip();
128 let size = Size::new(
129 text::width(&self.text).saturating_add(padding.horizontal()),
130 padding.vertical().saturating_add(1),
131 );
132 let pointer = cx.pointer_anywhere();
133 let covers_pointer = |rect: Rect| pointer.is_some_and(|(x, y)| rect.contains(x, y));
134 let sides = [self.placement, self.placement.opposite(), Placement::Right, Placement::Left];
135 let candidates = sides.map(|side| placement::place(anchor, size, screen, side).0);
136 let Some(rect) = candidates
138 .iter()
139 .find(|rect| !covers_pointer(**rect) && rect.intersect(anchor).is_empty())
140 .or_else(|| candidates.iter().find(|rect| !covers_pointer(**rect)))
141 .copied()
142 else {
143 return;
144 };
145
146 let shown_since = cx.memory::<TooltipMemory>().shown_since.unwrap_or_default();
147 let enter = cx.env().theme().motion().enter;
148 let progress = cx.progress_since(shown_since, enter, Easing::EaseOut);
149 cx.clear(rect, background);
150 let inner = rect.inset(padding);
151 let shown = text::truncate(&self.text, inner.width).into_owned();
152 let fg = background.mix(foreground, progress);
153 cx.text(inner.x, inner.y, &shown, CellStyle { fg: Some(fg), ..text_style }, inner.width);
154 }
155
156 fn children(&self) -> &[Node<Msg>] {
157 &self.body
158 }
159
160 fn children_mut(&mut self) -> &mut [Node<Msg>] {
161 &mut self.body
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::runtime::{App, Command, Harness};
169 use crate::widget::View;
170 use crate::widgets::{Button, Text};
171
172 struct Demo {
173 on_focus: bool,
174 }
175
176 impl App for Demo {
177 type Msg = ();
178 fn update(&mut self, (): ()) -> Command<()> {
179 Command::none()
180 }
181 fn view(&self, ui: &mut View<'_, ()>) {
182 ui.column(|ui| {
183 ui.spacer().height(Length::Cells(2));
184 ui.row(|ui| {
185 ui.add_with(Tooltip::new("Restart all").on_focus(self.on_focus), |ui| {
186 ui.add(Button::new("Restart").on_press(())).id("restart");
187 });
188 ui.add_with(Tooltip::new("Nothing to click").placement(Placement::Above), |ui| {
189 ui.add(Text::new("status"));
190 });
191 })
192 .gap(2);
193 });
194 }
195 }
196
197 #[test]
198 fn appears_after_the_hover_delay_and_leaves_with_the_pointer() {
199 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
200 h.hover(3, 2);
201 assert!(!h.screen().contains("Restart all"));
202 h.advance(Duration::from_millis(300));
203 assert!(!h.screen().contains("Restart all"));
204 h.advance(Duration::from_millis(200));
205 assert_eq!(h.screen(), "\n\n▌ Restart status\n Restart all\n\n");
207 assert_eq!(h.bg(1, 3), h.env().theme().color("overlay"));
208 h.advance(Duration::from_millis(200));
209 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
210 h.hover(30, 4);
211 assert!(!h.screen().contains("Restart all"));
212 }
213
214 #[test]
215 fn plain_content_gets_a_tooltip_above() {
216 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
217 h.hover(15, 2).advance(Duration::from_secs(1));
218 assert_eq!(h.screen(), "\n Nothing to click\n Restart status\n\n\n");
219 }
220
221 #[test]
222 fn keyboard_focus_shows_it_at_once_when_asked() {
223 let mut quiet = Harness::new(Demo { on_focus: false }, 40, 5);
224 quiet.press("tab");
225 assert!(!quiet.screen().contains("Restart all"));
226 let mut h = Harness::new(Demo { on_focus: true }, 40, 5);
227 h.set_reduced_motion(true).press("tab");
228 assert!(h.screen().contains("Restart all"));
229 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
230 }
231
232 #[test]
233 fn never_covers_the_pointer_on_a_crowded_screen() {
234 struct Crowded;
235 impl App for Crowded {
236 type Msg = ();
237 fn update(&mut self, (): ()) -> Command<()> {
238 Command::none()
239 }
240 fn view(&self, ui: &mut View<'_, ()>) {
241 ui.add_with(Tooltip::new("Explained"), |ui| {
242 ui.add(Text::new("first row"));
243 ui.add(Text::new("second row"));
244 });
245 }
246 }
247 let mut h = Harness::new(Crowded, 20, 2);
249 h.hover(0, 1).advance(Duration::from_secs(1));
250 assert_eq!(h.screen(), " Explained\nsecond row\n");
251 h.hover(0, 0).advance(Duration::from_secs(1));
252 assert_eq!(h.screen(), "first row\n Explained\n");
253 }
254}