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