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() || cx.is_focused());
109 let delay = cx.env().theme().motion().hover_delay;
110 let memory = cx.memory::<TooltipMemory>();
111 memory.hovered_since = if hovered { Some(memory.hovered_since.unwrap_or(now)) } else { None };
112 let due = memory.hovered_since.map(|since| since + delay);
113 let visible = focused || due.is_some_and(|due| now >= due);
114 memory.shown_since = if visible { Some(memory.shown_since.unwrap_or(now)) } else { None };
115 if visible {
116 cx.request_overlay(area);
117 } else if let Some(due) = due {
118 cx.request_frame_in(due.saturating_sub(now));
119 }
120 }
121
122 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
123 let shown_since = cx.memory::<TooltipMemory>().shown_since.unwrap_or_default();
124 paint_tip(cx, anchor, &self.text, self.placement, shown_since);
125 }
126
127 fn children(&self) -> &[Node<Msg>] {
128 &self.body
129 }
130
131 fn children_mut(&mut self) -> &mut [Node<Msg>] {
132 &mut self.body
133 }
134}
135
136pub(crate) fn paint_tip(cx: &mut PaintCx<'_>, anchor: Rect, text: &str, placement: Placement, shown_since: Duration) {
140 paint_tip_lines(cx, anchor, &[text.to_owned()], placement, shown_since);
141}
142
143pub(crate) fn paint_wrapped_tip(
147 cx: &mut PaintCx<'_>,
148 anchor: Rect,
149 text: &str,
150 width: u16,
151 placement: Placement,
152 shown_since: Duration,
153) {
154 let padding = cx.style("tooltip", None, &[]).padding();
155 let lines = text::wrap(text, width.saturating_sub(padding.horizontal()).max(1));
156 paint_tip_lines(cx, anchor, &lines, placement, shown_since);
157}
158
159fn paint_tip_lines(cx: &mut PaintCx<'_>, anchor: Rect, lines: &[String], placement: Placement, shown_since: Duration) {
160 let style = cx.style("tooltip", None, &[]);
161 let padding = style.padding();
162 let text_style = style.text();
163 let background = text_style.bg.unwrap_or_else(|| cx.color("overlay"));
164 let foreground = text_style.fg.unwrap_or_else(|| cx.color("text"));
165 let screen = cx.clip();
166 let widest = lines.iter().map(|line| text::width(line)).max().unwrap_or(0);
167 let height = u16::try_from(lines.len()).unwrap_or(u16::MAX);
168 let size = Size::new(widest.saturating_add(padding.horizontal()), padding.vertical().saturating_add(height));
169 let pointer = cx.pointer_anywhere();
170 let covers_pointer = |rect: Rect| pointer.is_some_and(|(x, y)| rect.contains(x, y));
171 let sides = [placement, placement.opposite(), Placement::Right, Placement::Left];
172 let candidates = sides.map(|side| placement::place(anchor, size, screen, side).0);
173 let Some(rect) = candidates
175 .iter()
176 .find(|rect| !covers_pointer(**rect) && rect.intersect(anchor).is_empty())
177 .or_else(|| candidates.iter().find(|rect| !covers_pointer(**rect)))
178 .copied()
179 else {
180 return;
181 };
182
183 let enter = cx.env().theme().motion().enter;
184 let progress = cx.progress_since(shown_since, enter, Easing::EaseOut);
185 let grounds = cx.grounds_around(rect);
186 let lifted = cx.lift_for(rect, &grounds, Some(background));
188 let surface = lifted.map_or(background, |lift| lift.apply(background));
189 cx.clear(rect, background);
190 if let Some(lift) = lifted {
191 cx.lift(rect, lift);
192 }
193 let inner = rect.inset(padding);
194 let fg = surface.mix(foreground, progress);
195 for (y, line) in (inner.y..inner.bottom()).zip(lines) {
196 let shown = text::truncate(line, inner.width).into_owned();
197 cx.text(inner.x, y, &shown, CellStyle { fg: Some(fg), bg: None, ..text_style }, inner.width);
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::runtime::{App, Command, Harness};
205 use crate::widget::View;
206 use crate::widgets::{Button, Text};
207
208 struct Demo {
209 on_focus: bool,
210 }
211
212 impl App for Demo {
213 type Msg = ();
214 fn update(&mut self, (): ()) -> Command<()> {
215 Command::none()
216 }
217 fn view(&self, ui: &mut View<'_, ()>) {
218 ui.column(|ui| {
219 ui.spacer().height(Length::Cells(2));
220 ui.row(|ui| {
221 ui.add_with(Tooltip::new("Restart all").on_focus(self.on_focus), |ui| {
222 ui.add(Button::new("Restart").on_press(())).id("restart");
223 });
224 ui.add_with(Tooltip::new("Nothing to click").placement(Placement::Above), |ui| {
225 ui.add(Text::new("status"));
226 });
227 })
228 .gap(2);
229 });
230 }
231 }
232
233 #[test]
234 fn appears_after_the_hover_delay_and_leaves_with_the_pointer() {
235 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
236 h.hover(3, 2);
237 assert!(!h.screen().contains("Restart all"));
238 h.advance(Duration::from_millis(300));
239 assert!(!h.screen().contains("Restart all"));
240 h.advance(Duration::from_millis(200));
241 assert_eq!(h.screen(), "\n\n▌ Restart status\n Restart all\n\n");
243 assert_eq!(h.bg(1, 3), h.env().theme().color("overlay"));
244 h.advance(Duration::from_millis(200));
245 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
246 h.hover(30, 4);
247 assert!(!h.screen().contains("Restart all"));
248 }
249
250 #[test]
251 fn plain_content_gets_a_tooltip_above() {
252 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
253 h.hover(15, 2).advance(Duration::from_secs(1));
254 assert_eq!(h.screen(), "\n Nothing to click\n Restart status\n\n\n");
255 }
256
257 #[test]
258 fn keyboard_focus_shows_it_at_once_when_asked() {
259 let mut quiet = Harness::new(Demo { on_focus: false }, 40, 5);
260 quiet.press("tab");
261 assert!(!quiet.screen().contains("Restart all"));
262 let mut h = Harness::new(Demo { on_focus: true }, 40, 5);
263 h.set_reduced_motion(true).press("tab");
264 assert!(h.screen().contains("Restart all"));
265 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
266 }
267
268 #[test]
269 fn never_covers_the_pointer_on_a_crowded_screen() {
270 struct Crowded;
271 impl App for Crowded {
272 type Msg = ();
273 fn update(&mut self, (): ()) -> Command<()> {
274 Command::none()
275 }
276 fn view(&self, ui: &mut View<'_, ()>) {
277 ui.add_with(Tooltip::new("Explained"), |ui| {
278 ui.add(Text::new("first row"));
279 ui.add(Text::new("second row"));
280 });
281 }
282 }
283 let mut h = Harness::new(Crowded, 20, 2);
285 h.hover(0, 1).advance(Duration::from_secs(1));
286 assert_eq!(h.screen(), " Explained\nsecond row\n");
287 h.hover(0, 0).advance(Duration::from_secs(1));
288 assert_eq!(h.screen(), "first row\n Explained\n");
289 }
290}