Skip to main content

repose_material/material3/
tooltip.rs

1#![allow(non_snake_case)]
2
3use std::rc::Rc;
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use repose_core::*;
7use repose_ui::{
8    Box, Text, TextStyle,
9    ViewExt,
10    anim::animate_f32,
11};
12
13use super::*;
14
15static TOOLTIP_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Configuration for tooltip.
18#[derive(Clone, Debug)]
19pub struct TooltipConfig {
20    pub modifier: Modifier,
21    pub container_color: Color,
22    pub content_color: Color,
23    pub offset_y: f32,
24    pub horizontal_padding: f32,
25    pub vertical_padding: f32,
26    pub has_action: bool,
27    pub enable_user_input: bool,
28    pub focusable: bool,
29    pub max_width: f32,
30    pub tonal_elevation: f32,
31    pub shadow_elevation: f32,
32}
33
34impl Default for TooltipConfig {
35    fn default() -> Self {
36        Self {
37            modifier: Modifier::new(),
38            container_color: TooltipDefaults::container_color(),
39            content_color: TooltipDefaults::content_color(),
40            offset_y: TooltipDefaults::OFFSET_Y,
41            horizontal_padding: TooltipDefaults::HORIZONTAL_PADDING,
42            vertical_padding: TooltipDefaults::VERTICAL_PADDING,
43            has_action: false,
44            enable_user_input: true,
45            focusable: false,
46            max_width: TooltipDefaults::MAX_WIDTH,
47            tonal_elevation: 0.0,
48            shadow_elevation: 0.0,
49        }
50    }
51}
52
53/// State controlling tooltip visibility.
54pub struct TooltipState {
55    visible: Signal<bool>,
56}
57
58impl TooltipState {
59    pub fn new() -> Rc<Self> {
60        Rc::new(Self {
61            visible: signal(false),
62        })
63    }
64
65    pub fn is_visible(&self) -> bool {
66        self.visible.get()
67    }
68
69    pub fn show(&self) {
70        self.visible.set(true);
71    }
72
73    pub fn dismiss(&self) {
74        self.visible.set(false);
75    }
76}
77
78/// Wraps `content` with a tooltip label shown above it when `state` is visible.
79///
80/// When [`TooltipConfig::enable_user_input`] is true (default), the tooltip is
81/// shown on pointer hover and dismissed on leave.
82///
83/// Usage:
84/// ```ignore
85/// let tip = TooltipState::new();
86/// TooltipBox("I'm a tooltip", tip.clone(), Modifier::new(), Button("Hover me", {
87///     let tip = tip.clone();
88///     move || tip.show()
89/// }));
90/// ```
91pub fn TooltipBox(
92    text: impl Into<String>,
93    state: Rc<TooltipState>,
94    content: View,
95    config: TooltipConfig,
96) -> View {
97    let text: Rc<str> = Rc::from(text.into());
98    let th = theme();
99    let spec = th.motion.overlay;
100    let id = remember(|| TOOLTIP_COUNTER.fetch_add(1, Ordering::Relaxed));
101
102    let alpha = animate_f32(
103        format!("tooltip_alpha_{id}"),
104        if state.is_visible() { 1.0 } else { 0.0 },
105        spec,
106    );
107
108    let tooltip_visible = state.is_visible() || alpha > 0.01;
109    let scale = 0.92 + 0.08 * alpha;
110
111    let mut host = config
112        .modifier
113        .align_self(AlignSelf::FLEX_START)
114        .flex_shrink(0.0);
115
116    if config.enable_user_input {
117        let enter = state.clone();
118        let leave = state.clone();
119        host = host.hoverable(
120            move || enter.show(),
121            move || leave.dismiss(),
122        );
123    }
124
125    Box(host).child((
126        content,
127        if tooltip_visible {
128            Box(Modifier::new()
129                .absolute()
130                .offset(Some(0.0), Some(config.offset_y), Some(0.0), None)
131                .justify_content(JustifyContent::CENTER)
132                .align_items(AlignItems::CENTER)
133                .hit_passthrough()
134                .render_z_index(10_000.0)
135                .alpha(alpha))
136            .child(
137                Box(Modifier::new()
138                    .background(config.container_color)
139                    .clip_rounded(th.shapes.extra_small)
140                    .padding_values(PaddingValues {
141                        left: config.horizontal_padding,
142                        right: config.horizontal_padding,
143                        top: config.vertical_padding,
144                        bottom: config.vertical_padding,
145                    })
146                    .max_width(config.max_width)
147                    .flex_shrink(0.0)
148                    .scale(scale)
149                    .hit_passthrough()
150                    .then({
151                        let mut m = Modifier::new();
152                        if config.shadow_elevation > 0.0 {
153                            m = m.shadow(config.shadow_elevation, 0.0);
154                        }
155                        if config.tonal_elevation > 0.0 {
156                            m = m.state_elevation(StateElevation {
157                                default: config.tonal_elevation,
158                                hovered: config.tonal_elevation,
159                                pressed: config.tonal_elevation,
160                                disabled: 0.0,
161                            });
162                        }
163                        m
164                    }))
165                .child(
166                    Text((*text).to_string())
167                        .color(config.content_color)
168                        .size(th.typography.label_medium),
169                ),
170            )
171        } else {
172            Box(Modifier::new())
173        },
174    ))
175}