Skip to main content

qframe/widgets/
tooltip.rs

1//! Tooltips: a short explanation that appears next to a widget the pointer rests on.
2
3use 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
12/// Wraps widgets and shows a short text next to them after the pointer rests on them for the
13/// theme's `motion.hover-delay`.
14///
15/// The text is one row on the overlay surface, below the widgets by default. It flips to the
16/// other side when there is no room, and moves to another side when it would cover the pointer.
17/// It disappears as soon as the pointer leaves. With [`on_focus`](Tooltip::on_focus) it also
18/// shows, at once, while keyboard focus is inside. The text fades in over `motion.enter`.
19///
20/// Style keys: `tooltip` (`bg`, `fg`, `padding`).
21///
22/// ```
23/// use qframe::prelude::*;
24/// use qframe::widgets::Tooltip;
25///
26/// struct Toolbar;
27///
28/// impl App for Toolbar {
29///     type Msg = ();
30///     fn update(&mut self, (): ()) -> Command<()> {
31///         Command::none()
32///     }
33///     fn view(&self, ui: &mut View<'_, ()>) {
34///         ui.add_with(Tooltip::new("Restart every container"), |ui| {
35///             ui.add(Button::new("Restart").on_press(()));
36///         });
37///     }
38/// }
39///
40/// let mut app = Harness::new(Toolbar, 40, 4);
41/// app.hover(3, 0).advance(std::time::Duration::from_secs(1));
42/// assert!(app.screen().contains("Restart every container"));
43/// ```
44pub 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    /// A tooltip saying `text`. Add the widgets it explains with
59    /// [`View::add_with`](crate::widget::View::add_with).
60    #[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    /// The preferred side; [`Placement::Below`] by default.
71    #[must_use]
72    pub fn placement(mut self, placement: Placement) -> Self {
73        self.placement = placement;
74        self
75    }
76
77    /// Also shows the tooltip while keyboard focus is inside the wrapped widgets.
78    #[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        // Registered first so plain content still reports hover; interactive children sit on top.
101        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        // Inside a settings row the list lends its focus to the row's control, so that counts too.
108        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
136/// Paints a tooltip saying `text` beside `anchor`, preferably on side `placement`, fading in since
137/// `shown_since`. Widgets that explain a part of themselves (rather than wrapping it in a
138/// [`Tooltip`]) call it from their overlay, so every tip looks and moves the same.
139pub(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
143/// Paints a tip like [`paint_tip`] whose text is wrapped to at most `width` cells, padding
144/// included, so a longer explanation stays next to what it explains instead of spreading over
145/// the controls beside it.
146pub(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    // Beside the anchor if possible; on a crowded screen over it, but never under the pointer.
174    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    // The text fades in from the surface as it will show, lifted or not.
187    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        // The hovered button shows its pillar; the tooltip sits below it.
242        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        // No side has room, so the tooltip goes over the widget, on the row the pointer is not on.
284        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}