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        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
135/// Paints a tooltip saying `text` beside `anchor`, preferably on side `placement`, fading in since
136/// `shown_since`. Widgets that explain a part of themselves (rather than wrapping it in a
137/// [`Tooltip`]) call it from their overlay, so every tip looks and moves the same.
138pub(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    // Beside the anchor if possible; on a crowded screen over it, but never under the pointer.
151    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    // The text fades in from the surface as it will show, lifted or not.
164    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        // The hovered button shows its pillar; the tooltip sits below it.
217        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        // No side has room, so the tooltip goes over the widget, on the row the pointer is not on.
259        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}