veloren_voxygen/ui/widgets/
tooltip.rs

1use super::image_frame::ImageFrame;
2use conrod_core::{
3    Color, Colorable, FontSize, Positionable, Sizeable, Ui, UiCell, Widget, WidgetCommon,
4    WidgetStyle, builder_method, builder_methods, image, input::global::Global,
5    position::Dimension, text, widget, widget_ids,
6};
7use std::time::{Duration, Instant};
8#[derive(Copy, Clone)]
9struct Hover(widget::Id, [f64; 2]);
10#[derive(Copy, Clone)]
11enum HoverState {
12    Hovering(Hover),
13    Fading(Instant, Hover, Option<(Instant, widget::Id)>),
14    Start(Instant, widget::Id),
15    None,
16}
17
18// Spacing between the tooltip and mouse
19const MOUSE_PAD_Y: f64 = 15.0;
20const TEXT_COLOR: Color = Color::Rgba(1.0, 1.0, 1.0, 1.0); // Default text color
21
22pub struct TooltipManager {
23    tooltip_id: widget::Id,
24    state: HoverState,
25    // How long before a tooltip is displayed when hovering
26    hover_dur: Duration,
27    // How long it takes a tooltip to disappear
28    fade_dur: Duration,
29    // Current scaling of the ui
30    logical_scale_factor: f64,
31}
32impl TooltipManager {
33    pub fn new(
34        mut generator: widget::id::Generator,
35        hover_dur: Duration,
36        fade_dur: Duration,
37        logical_scale_factor: f64,
38    ) -> Self {
39        Self {
40            tooltip_id: generator.next(),
41            state: HoverState::None,
42            hover_dur,
43            fade_dur,
44            logical_scale_factor,
45        }
46    }
47
48    pub fn maintain(&mut self, input: &Global, logical_scale_factor: f64) {
49        self.logical_scale_factor = logical_scale_factor;
50
51        let current = &input.current;
52
53        if let Some(um_id) = current.widget_under_mouse {
54            match self.state {
55                HoverState::Hovering(hover) if um_id == hover.0 => (),
56                HoverState::Hovering(hover) => {
57                    self.state =
58                        HoverState::Fading(Instant::now(), hover, Some((Instant::now(), um_id)))
59                },
60                HoverState::Fading(_, _, Some((_, id))) if um_id == id => {},
61                HoverState::Fading(start, hover, _) => {
62                    self.state = HoverState::Fading(start, hover, Some((Instant::now(), um_id)))
63                },
64                HoverState::Start(_, id) if um_id == id => (),
65                HoverState::Start(_, _) | HoverState::None => {
66                    self.state = HoverState::Start(Instant::now(), um_id)
67                },
68            }
69        } else {
70            match self.state {
71                HoverState::Hovering(hover) => {
72                    self.state = HoverState::Fading(Instant::now(), hover, None)
73                },
74                HoverState::Fading(start, hover, Some((_, _))) => {
75                    self.state = HoverState::Fading(start, hover, None)
76                },
77                HoverState::Start(_, _) => self.state = HoverState::None,
78                HoverState::Fading(_, _, None) | HoverState::None => (),
79            }
80        }
81
82        // Handle fade timing
83        if let HoverState::Fading(start, _, maybe_hover) = self.state {
84            if start.elapsed() > self.fade_dur {
85                self.state = match maybe_hover {
86                    Some((start, hover)) => HoverState::Start(start, hover),
87                    None => HoverState::None,
88                };
89            }
90        }
91    }
92
93    // return true if visible
94    fn set_tooltip(
95        &mut self,
96        tooltip: &Tooltip,
97        title_text: &str,
98        desc_text: &str,
99        title_col: Color,
100        img_id: Option<image::Id>,
101        image_dims: Option<(f64, f64)>,
102        src_id: widget::Id,
103        ui: &mut UiCell,
104    ) -> bool {
105        let tooltip_id = self.tooltip_id;
106        let mp_h = MOUSE_PAD_Y / self.logical_scale_factor;
107
108        let tooltip = |transparency, mouse_pos: [f64; 2], ui: &mut UiCell| {
109            // Fill in text and the potential image beforehand to get an accurate size for
110            // spacing
111            let tooltip = tooltip
112                .clone()
113                .title(title_text)
114                .desc(desc_text)
115                .title_col(title_col)
116                .image(img_id)
117                .image_dims(image_dims);
118
119            let [t_w, t_h] = tooltip.get_wh(ui).unwrap_or([0.0, 0.0]);
120            let [m_x, m_y] = [mouse_pos[0], mouse_pos[1]];
121            let (w_w, w_h) = (ui.win_w, ui.win_h);
122
123            // Determine position based on size and mouse position
124            // Flow to the top left of the mouse when there is space
125            let x = if (m_x + w_w / 2.0) > t_w {
126                m_x - t_w / 2.0
127            } else {
128                m_x + t_w / 2.0
129            };
130            let y = if w_h - (m_y + w_h / 2.0) > t_h + mp_h {
131                m_y + mp_h + t_h / 2.0
132            } else {
133                m_y - mp_h - t_h / 2.0
134            };
135            tooltip
136                .floating(true)
137                .transparency(transparency)
138                .x_y(x, y)
139                .set(tooltip_id, ui);
140        };
141
142        match self.state {
143            HoverState::Hovering(Hover(id, xy)) if id == src_id => tooltip(1.0, xy, ui),
144            HoverState::Fading(start, Hover(id, xy), _) if id == src_id => tooltip(
145                (0.1f32 - start.elapsed().as_millis() as f32 / self.hover_dur.as_millis() as f32)
146                    .max(0.0),
147                xy,
148                ui,
149            ),
150            HoverState::Start(start, id) if id == src_id && start.elapsed() > self.hover_dur => {
151                let xy = ui.global_input().current.mouse.xy;
152                self.state = HoverState::Hovering(Hover(id, xy));
153                tooltip(1.0, xy, ui);
154            },
155            _ => (),
156        }
157        matches!(self.state, HoverState::Hovering(Hover(id, _)) if id == src_id)
158    }
159}
160
161pub struct Tooltipped<'a, W> {
162    inner: W,
163    tooltip_manager: &'a mut TooltipManager,
164    title_text: &'a str,
165    desc_text: &'a str,
166    img_id: Option<image::Id>,
167    image_dims: Option<(f64, f64)>,
168    tooltip: &'a Tooltip<'a>,
169    title_col: Color,
170}
171impl<W: Widget> Tooltipped<'_, W> {
172    pub fn tooltip_image(mut self, img_id: image::Id) -> Self {
173        self.img_id = Some(img_id);
174        self
175    }
176
177    pub fn tooltip_image_dims(mut self, dims: (f64, f64)) -> Self {
178        self.image_dims = Some(dims);
179        self
180    }
181
182    pub fn set_ext(self, id: widget::Id, ui: &mut UiCell) -> (W::Event, bool) {
183        let event = self.inner.set(id, ui);
184        let visible = self.tooltip_manager.set_tooltip(
185            self.tooltip,
186            self.title_text,
187            self.desc_text,
188            self.title_col,
189            self.img_id,
190            self.image_dims,
191            id,
192            ui,
193        );
194        (event, visible)
195    }
196
197    pub fn set(self, id: widget::Id, ui: &mut UiCell) -> W::Event { self.set_ext(id, ui).0 }
198}
199
200pub trait Tooltipable {
201    // If `Tooltip` is expensive to construct accept a closure here instead.
202    fn with_tooltip<'a>(
203        self,
204        tooltip_manager: &'a mut TooltipManager,
205        title_text: &'a str,
206        desc_text: &'a str,
207        tooltip: &'a Tooltip<'a>,
208        title_col: Color,
209    ) -> Tooltipped<'a, Self>
210    where
211        Self: Sized;
212}
213impl<W: Widget> Tooltipable for W {
214    fn with_tooltip<'a>(
215        self,
216        tooltip_manager: &'a mut TooltipManager,
217        title_text: &'a str,
218        desc_text: &'a str,
219        tooltip: &'a Tooltip<'a>,
220        title_col: Color,
221    ) -> Tooltipped<'a, W> {
222        Tooltipped {
223            inner: self,
224            tooltip_manager,
225            title_text,
226            desc_text,
227            img_id: None,
228            image_dims: None,
229            tooltip,
230            title_col,
231        }
232    }
233}
234
235/// Vertical spacing between elements of the tooltip
236const V_PAD: f64 = 10.0;
237/// Horizontal spacing between elements of the tooltip
238const H_PAD: f64 = 10.0;
239/// Default portion of inner width that goes to an image
240const IMAGE_W_FRAC: f64 = 0.3;
241/// Default width multiplied by the description font size
242const DEFAULT_CHAR_W: f64 = 30.0;
243/// Text vertical spacing factor to account for overhanging text
244const TEXT_SPACE_FACTOR: f64 = 0.35;
245
246/// A widget for displaying tooltips
247#[derive(Clone, WidgetCommon)]
248pub struct Tooltip<'a> {
249    #[conrod(common_builder)]
250    common: widget::CommonBuilder,
251    title_text: &'a str,
252    desc_text: &'a str,
253    title_col: Color,
254    image: Option<image::Id>,
255    image_dims: Option<(f64, f64)>,
256    style: Style,
257    transparency: f32,
258    image_frame: ImageFrame,
259}
260
261#[derive(Clone, Debug, Default, PartialEq, WidgetStyle)]
262pub struct Style {
263    #[conrod(default = "Color::Rgba(1.0, 1.0, 1.0, 1.0)")]
264    pub color: Option<Color>,
265    title: widget::text::Style,
266    desc: widget::text::Style,
267    // add background imgs here
268}
269
270widget_ids! {
271    struct Ids {
272        title,
273        desc,
274        image_frame,
275        image,
276    }
277}
278
279pub struct State {
280    ids: Ids,
281}
282
283impl<'a> Tooltip<'a> {
284    builder_methods! {
285        pub desc_text_color { style.desc.color = Some(Color) }
286        pub title_font_size { style.title.font_size = Some(FontSize) }
287        pub desc_font_size { style.desc.font_size = Some(FontSize) }
288        pub title_justify { style.title.justify = Some(text::Justify) }
289        pub desc_justify { style.desc.justify = Some(text::Justify) }
290        image { image = Option<image::Id> }
291        title { title_text = &'a str }
292        desc { desc_text = &'a str }
293        image_dims { image_dims = Option<(f64, f64)> }
294        transparency { transparency = f32 }
295        title_col { title_col = Color}
296    }
297
298    pub fn new(image_frame: ImageFrame) -> Self {
299        Tooltip {
300            common: widget::CommonBuilder::default(),
301            style: Style::default(),
302            title_text: "",
303            desc_text: "",
304            transparency: 1.0,
305            image_frame,
306            image: None,
307            image_dims: None,
308            title_col: TEXT_COLOR,
309        }
310    }
311
312    // /// Align the text to the left of its bounding **Rect**'s *x* axis range.
313    // pub fn left_justify(self) -> Self {
314    //     self.justify(text::Justify::Left)
315    // }
316
317    // /// Align the text to the middle of its bounding **Rect**'s *x* axis range.
318    // pub fn center_justify(self) -> Self {
319    //     self.justify(text::Justify::Center)
320    // }
321
322    // /// Align the text to the right of its bounding **Rect**'s *x* axis range.
323    // pub fn right_justify(self) -> Self {
324    //     self.justify(text::Justify::Right)
325    // }
326
327    fn text_image_width(&self, total_width: f64) -> (f64, f64) {
328        let inner_width = (total_width - H_PAD * 2.0).max(0.0);
329        // Image defaults to 30% of the width
330        let image_w = if self.image.is_some() {
331            match self.image_dims {
332                Some((w, _)) => w,
333                None => (inner_width - H_PAD).max(0.0) * IMAGE_W_FRAC,
334            }
335        } else {
336            0.0
337        };
338        // Text gets the remaining width
339        let text_w = (inner_width
340            - if self.image.is_some() {
341                image_w + H_PAD
342            } else {
343                0.0
344            })
345        .max(0.0);
346
347        (text_w, image_w)
348    }
349
350    /// Specify the font used for displaying the text.
351    #[must_use]
352    pub fn font_id(mut self, font_id: text::font::Id) -> Self {
353        self.style.title.font_id = Some(Some(font_id));
354        self.style.desc.font_id = Some(Some(font_id));
355        self
356    }
357}
358
359impl Widget for Tooltip<'_> {
360    type Event = ();
361    type State = State;
362    type Style = Style;
363
364    fn init_state(&self, id_gen: widget::id::Generator) -> Self::State {
365        State {
366            ids: Ids::new(id_gen),
367        }
368    }
369
370    fn style(&self) -> Self::Style { self.style.clone() }
371
372    fn update(self, args: widget::UpdateArgs<Self>) {
373        let widget::UpdateArgs {
374            id,
375            state,
376            rect,
377            style,
378            ui,
379            ..
380        } = args;
381
382        // Widths
383        let (text_w, image_w) = self.text_image_width(rect.w());
384
385        // Apply transparency
386        let color = style.color(ui.theme()).alpha(self.transparency);
387
388        // Background image frame
389        self.image_frame
390            .wh(rect.dim())
391            .xy(rect.xy())
392            .graphics_for(id)
393            .parent(id)
394            .color(color)
395            .set(state.ids.image_frame, ui);
396
397        // Image
398        if let Some(img_id) = self.image {
399            widget::Image::new(img_id)
400                .w_h(image_w, self.image_dims.map_or(image_w, |(_, h)| h))
401                .graphics_for(id)
402                .parent(id)
403                .color(Some(color))
404                .top_left_with_margins_on(state.ids.image_frame, V_PAD, H_PAD)
405                .set(state.ids.image, ui);
406        }
407
408        // Spacing for overhanging text
409        let title_space = self.style.title.font_size(&ui.theme) as f64 * TEXT_SPACE_FACTOR;
410
411        // Title of tooltip
412        if !self.title_text.is_empty() {
413            let title = widget::Text::new(self.title_text)
414                .w(text_w)
415                .graphics_for(id)
416                .parent(id)
417                .with_style(self.style.title)
418                // Apply transparency
419                .color(self.title_col);
420
421            if self.image.is_some() {
422                title
423                    .right_from(state.ids.image, H_PAD)
424                    .align_top_of(state.ids.image)
425            } else {
426                title.top_left_with_margins_on(state.ids.image_frame, V_PAD, H_PAD)
427            }
428            .set(state.ids.title, ui);
429        }
430
431        // Description of tooltip
432        let desc = widget::Text::new(self.desc_text)
433            .w(text_w)
434            .graphics_for(id)
435            .parent(id)
436            // Apply transparency
437            .color(style.desc.color(ui.theme()).alpha(self.transparency))
438            .with_style(self.style.desc);
439
440        if !self.title_text.is_empty() {
441            desc.down_from(state.ids.title, V_PAD * 0.5 + title_space)
442                .align_left_of(state.ids.title)
443        } else if self.image.is_some() {
444            desc.right_from(state.ids.image, H_PAD)
445                .align_top_of(state.ids.image)
446        } else {
447            desc.top_left_with_margins_on(state.ids.image_frame, V_PAD, H_PAD)
448        }
449        .set(state.ids.desc, ui);
450    }
451
452    /// Default width is based on the description font size unless the text is
453    /// small enough to fit on a single line
454    fn default_x_dimension(&self, ui: &Ui) -> Dimension {
455        let single_line_title_w = widget::Text::new(self.title_text)
456            .with_style(self.style.title)
457            .get_w(ui)
458            .unwrap_or(0.0);
459        let single_line_desc_w = widget::Text::new(self.desc_text)
460            .with_style(self.style.desc)
461            .get_w(ui)
462            .unwrap_or(0.0);
463
464        let text_w = single_line_title_w.max(single_line_desc_w);
465        let inner_w = if self.image.is_some() {
466            match self.image_dims {
467                Some((w, _)) => w + text_w + H_PAD,
468                None => text_w / (1.0 - IMAGE_W_FRAC) + H_PAD,
469            }
470        } else {
471            text_w
472        };
473
474        let width =
475            inner_w.min(self.style.desc.font_size(&ui.theme) as f64 * DEFAULT_CHAR_W) + 2.0 * H_PAD;
476        Dimension::Absolute(width)
477    }
478
479    fn default_y_dimension(&self, ui: &Ui) -> Dimension {
480        let (text_w, image_w) = self.text_image_width(self.get_w(ui).unwrap_or(0.0));
481        let title_h = if self.title_text.is_empty() {
482            0.0
483        } else {
484            widget::Text::new(self.title_text)
485                .with_style(self.style.title)
486                .w(text_w)
487                .get_h(ui)
488                .unwrap_or(0.0)
489                + self.style.title.font_size(&ui.theme) as f64 * TEXT_SPACE_FACTOR
490                + 0.5 * V_PAD
491        };
492        let desc_h = if self.desc_text.is_empty() {
493            0.0
494        } else {
495            widget::Text::new(self.desc_text)
496                .with_style(self.style.desc)
497                .w(text_w)
498                .get_h(ui)
499                .unwrap_or(0.0)
500                + self.style.desc.font_size(&ui.theme) as f64 * TEXT_SPACE_FACTOR
501        };
502        // Image defaults to square shape
503        let image_h = self.image_dims.map_or(image_w, |(_, h)| h);
504        // Title height + desc height + padding/spacing
505        let height = (title_h + desc_h).max(image_h) + 2.0 * V_PAD;
506        Dimension::Absolute(height)
507    }
508}
509
510impl Colorable for Tooltip<'_> {
511    builder_method!(color { style.color = Some(Color) });
512}