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 let grounds = cx.grounds_around(rect);
150 let lifted = cx.lift_for(rect, &grounds, Some(background));
152 let surface = lifted.map_or(background, |lift| lift.apply(background));
153 cx.clear(rect, background);
154 if let Some(lift) = lifted {
155 cx.lift(rect, lift);
156 }
157 let inner = rect.inset(padding);
158 let shown = text::truncate(&self.text, inner.width).into_owned();
159 let fg = surface.mix(foreground, progress);
160 cx.text(inner.x, inner.y, &shown, CellStyle { fg: Some(fg), bg: None, ..text_style }, inner.width);
161 }
162
163 fn children(&self) -> &[Node<Msg>] {
164 &self.body
165 }
166
167 fn children_mut(&mut self) -> &mut [Node<Msg>] {
168 &mut self.body
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::runtime::{App, Command, Harness};
176 use crate::widget::View;
177 use crate::widgets::{Button, Text};
178
179 struct Demo {
180 on_focus: bool,
181 }
182
183 impl App for Demo {
184 type Msg = ();
185 fn update(&mut self, (): ()) -> Command<()> {
186 Command::none()
187 }
188 fn view(&self, ui: &mut View<'_, ()>) {
189 ui.column(|ui| {
190 ui.spacer().height(Length::Cells(2));
191 ui.row(|ui| {
192 ui.add_with(Tooltip::new("Restart all").on_focus(self.on_focus), |ui| {
193 ui.add(Button::new("Restart").on_press(())).id("restart");
194 });
195 ui.add_with(Tooltip::new("Nothing to click").placement(Placement::Above), |ui| {
196 ui.add(Text::new("status"));
197 });
198 })
199 .gap(2);
200 });
201 }
202 }
203
204 #[test]
205 fn appears_after_the_hover_delay_and_leaves_with_the_pointer() {
206 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
207 h.hover(3, 2);
208 assert!(!h.screen().contains("Restart all"));
209 h.advance(Duration::from_millis(300));
210 assert!(!h.screen().contains("Restart all"));
211 h.advance(Duration::from_millis(200));
212 assert_eq!(h.screen(), "\n\n▌ Restart status\n Restart all\n\n");
214 assert_eq!(h.bg(1, 3), h.env().theme().color("overlay"));
215 h.advance(Duration::from_millis(200));
216 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
217 h.hover(30, 4);
218 assert!(!h.screen().contains("Restart all"));
219 }
220
221 #[test]
222 fn plain_content_gets_a_tooltip_above() {
223 let mut h = Harness::new(Demo { on_focus: false }, 40, 5);
224 h.hover(15, 2).advance(Duration::from_secs(1));
225 assert_eq!(h.screen(), "\n Nothing to click\n Restart status\n\n\n");
226 }
227
228 #[test]
229 fn keyboard_focus_shows_it_at_once_when_asked() {
230 let mut quiet = Harness::new(Demo { on_focus: false }, 40, 5);
231 quiet.press("tab");
232 assert!(!quiet.screen().contains("Restart all"));
233 let mut h = Harness::new(Demo { on_focus: true }, 40, 5);
234 h.set_reduced_motion(true).press("tab");
235 assert!(h.screen().contains("Restart all"));
236 assert_eq!(h.fg(2, 3), h.env().theme().color("text"));
237 }
238
239 #[test]
240 fn never_covers_the_pointer_on_a_crowded_screen() {
241 struct Crowded;
242 impl App for Crowded {
243 type Msg = ();
244 fn update(&mut self, (): ()) -> Command<()> {
245 Command::none()
246 }
247 fn view(&self, ui: &mut View<'_, ()>) {
248 ui.add_with(Tooltip::new("Explained"), |ui| {
249 ui.add(Text::new("first row"));
250 ui.add(Text::new("second row"));
251 });
252 }
253 }
254 let mut h = Harness::new(Crowded, 20, 2);
256 h.hover(0, 1).advance(Duration::from_secs(1));
257 assert_eq!(h.screen(), " Explained\nsecond row\n");
258 h.hover(0, 0).advance(Duration::from_secs(1));
259 assert_eq!(h.screen(), "first row\n Explained\n");
260 }
261}