Skip to main content

armas_basic/components/
tooltip.rs

1//! Tooltip Component
2//!
3//! Contextual help tooltips styled like shadcn/ui Tooltip.
4//! Appears on hover with configurable delay and position.
5
6use crate::ext::ArmasContextExt;
7use egui::{pos2, vec2, Color32, FontId, Rect, Response, Shape, Stroke, Ui, Vec2};
8
9// shadcn Tooltip constants
10const CORNER_RADIUS: f32 = 6.0; // rounded-md
11const PADDING_X: f32 = 12.0; // px-3
12const PADDING_Y: f32 = 6.0; // py-1.5
13                            // Font size resolved from theme.typography.sm at show-time
14const ARROW_SIZE: f32 = 5.0; // size-2.5 (10px / 2 for triangle)
15
16/// Tooltip position relative to the target
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TooltipPosition {
19    /// Above the target
20    Top,
21    /// Below the target
22    Bottom,
23    /// To the left of the target
24    Left,
25    /// To the right of the target
26    Right,
27}
28
29/// Tooltip component that shows contextual help on hover
30///
31/// # Example
32///
33/// ```rust,no_run
34/// # use egui::Ui;
35/// # fn example(ui: &mut Ui) {
36/// use armas_basic::Tooltip;
37///
38/// let response = ui.button("Hover me");
39/// Tooltip::new("This is helpful information").show(ui, &response);
40/// # }
41/// ```
42pub struct Tooltip {
43    text: String,
44    position: Option<TooltipPosition>,
45    max_width: f32,
46    delay_ms: u64,
47    show_arrow: bool,
48}
49
50impl Tooltip {
51    /// Create a new tooltip
52    pub fn new(text: impl Into<String>) -> Self {
53        Self {
54            text: text.into(),
55            position: None, // Auto by default
56            max_width: 300.0,
57            delay_ms: 0, // shadcn default: no delay
58            show_arrow: true,
59        }
60    }
61
62    /// Set the tooltip position (default: auto-detect)
63    #[must_use]
64    pub const fn position(mut self, position: TooltipPosition) -> Self {
65        self.position = Some(position);
66        self
67    }
68
69    /// Set maximum width for text wrapping
70    #[must_use]
71    pub const fn max_width(mut self, width: f32) -> Self {
72        self.max_width = width;
73        self
74    }
75
76    /// Set hover delay in milliseconds before showing tooltip
77    #[must_use]
78    pub const fn delay(mut self, delay_ms: u64) -> Self {
79        self.delay_ms = delay_ms;
80        self
81    }
82
83    /// Show or hide the arrow pointer
84    #[must_use]
85    pub const fn arrow(mut self, show: bool) -> Self {
86        self.show_arrow = show;
87        self
88    }
89
90    /// Show tooltip for a UI element
91    pub fn show(self, ui: &mut Ui, target_response: &Response) -> bool {
92        let theme = ui.ctx().armas_theme();
93        let is_hovered = target_response.hovered();
94
95        // Use egui's memory to track hover start time
96        let hover_id = target_response.id.with("tooltip_hover");
97        let current_time = ui.ctx().input(|i| i.time);
98
99        let hover_start: Option<f64> = ui.ctx().data(|d| d.get_temp(hover_id));
100
101        if is_hovered {
102            // Start tracking hover time
103            if hover_start.is_none() {
104                ui.ctx().data_mut(|d| d.insert_temp(hover_id, current_time));
105                if self.delay_ms > 0 {
106                    ui.ctx().request_repaint();
107                    return false;
108                }
109            }
110
111            // Check if delay has elapsed
112            if let Some(start) = hover_start {
113                let elapsed_ms = ((current_time - start) * 1000.0) as u64;
114                if elapsed_ms < self.delay_ms {
115                    ui.ctx().request_repaint();
116                    return false;
117                }
118            }
119        } else {
120            // Clear hover state when not hovering
121            ui.ctx().data_mut(|d| d.remove::<f64>(hover_id));
122            return false;
123        }
124
125        // Calculate tooltip content size
126        let font_id = FontId::proportional(theme.typography.sm);
127        let padding = vec2(PADDING_X, PADDING_Y);
128
129        // shadcn uses inverted colors: bg-foreground text-background
130        let bg_color = theme.foreground();
131        let text_color = theme.background();
132
133        let text_galley = ui.painter().layout(
134            self.text.clone(),
135            font_id,
136            text_color,
137            self.max_width - padding.x * 2.0,
138        );
139
140        let text_size = text_galley.size();
141        let tooltip_size = text_size + padding * 2.0;
142        let arrow_offset = if self.show_arrow {
143            ARROW_SIZE + 2.0
144        } else {
145            4.0
146        };
147
148        // Determine position
149        let target_rect = target_response.rect;
150        let position = self.determine_position(ui, target_rect, tooltip_size, arrow_offset);
151        let tooltip_rect =
152            self.calculate_tooltip_rect(target_rect, tooltip_size, arrow_offset, position);
153
154        // Draw tooltip as an overlay (above everything else)
155        let layer_id = egui::LayerId::new(
156            egui::Order::Tooltip,
157            target_response.id.with("tooltip_layer"),
158        );
159        let painter = ui.ctx().layer_painter(layer_id);
160
161        // Background
162        painter.rect_filled(tooltip_rect, CORNER_RADIUS, bg_color);
163
164        // Arrow
165        if self.show_arrow {
166            self.draw_arrow(&painter, bg_color, target_rect, tooltip_rect, position);
167        }
168
169        // Text
170        painter.galley(tooltip_rect.min + padding, text_galley, text_color);
171
172        true
173    }
174
175    /// Determine the best position for the tooltip
176    fn determine_position(
177        &self,
178        ui: &Ui,
179        target_rect: Rect,
180        tooltip_size: Vec2,
181        arrow_offset: f32,
182    ) -> TooltipPosition {
183        if let Some(pos) = self.position {
184            return pos;
185        }
186
187        let screen_rect = ui.clip_rect();
188        let spacing = arrow_offset;
189
190        // Check available space in each direction
191        let space_above = target_rect.top() - screen_rect.top();
192        let space_below = screen_rect.bottom() - target_rect.bottom();
193        let space_left = target_rect.left() - screen_rect.left();
194        let space_right = screen_rect.right() - target_rect.right();
195
196        let needed_vertical = tooltip_size.y + spacing;
197        let needed_horizontal = tooltip_size.x + spacing;
198
199        // Prefer top, then bottom, then right, then left
200        if space_above >= needed_vertical {
201            TooltipPosition::Top
202        } else if space_below >= needed_vertical {
203            TooltipPosition::Bottom
204        } else if space_right >= needed_horizontal {
205            TooltipPosition::Right
206        } else if space_left >= needed_horizontal {
207            TooltipPosition::Left
208        } else {
209            TooltipPosition::Top
210        }
211    }
212
213    /// Calculate the tooltip rectangle based on position
214    fn calculate_tooltip_rect(
215        &self,
216        target_rect: Rect,
217        tooltip_size: Vec2,
218        arrow_offset: f32,
219        position: TooltipPosition,
220    ) -> Rect {
221        let target_center_x = target_rect.center().x;
222        let target_center_y = target_rect.center().y;
223
224        let min_pos = match position {
225            TooltipPosition::Top => pos2(
226                target_center_x - tooltip_size.x / 2.0,
227                target_rect.top() - tooltip_size.y - arrow_offset,
228            ),
229            TooltipPosition::Bottom => pos2(
230                target_center_x - tooltip_size.x / 2.0,
231                target_rect.bottom() + arrow_offset,
232            ),
233            TooltipPosition::Left => pos2(
234                target_rect.left() - tooltip_size.x - arrow_offset,
235                target_center_y - tooltip_size.y / 2.0,
236            ),
237            TooltipPosition::Right => pos2(
238                target_rect.right() + arrow_offset,
239                target_center_y - tooltip_size.y / 2.0,
240            ),
241        };
242
243        Rect::from_min_size(min_pos, tooltip_size)
244    }
245
246    /// Draw the arrow pointing to the target
247    fn draw_arrow(
248        &self,
249        painter: &egui::Painter,
250        bg_color: Color32,
251        _target_rect: Rect,
252        tooltip_rect: Rect,
253        position: TooltipPosition,
254    ) {
255        let size = ARROW_SIZE;
256
257        let (tip, base1, base2) = match position {
258            TooltipPosition::Top => {
259                // Arrow points down (at bottom of tooltip)
260                let tip = pos2(tooltip_rect.center().x, tooltip_rect.bottom() + size);
261                let base1 = pos2(tip.x - size, tooltip_rect.bottom());
262                let base2 = pos2(tip.x + size, tooltip_rect.bottom());
263                (tip, base1, base2)
264            }
265            TooltipPosition::Bottom => {
266                // Arrow points up (at top of tooltip)
267                let tip = pos2(tooltip_rect.center().x, tooltip_rect.top() - size);
268                let base1 = pos2(tip.x - size, tooltip_rect.top());
269                let base2 = pos2(tip.x + size, tooltip_rect.top());
270                (tip, base1, base2)
271            }
272            TooltipPosition::Left => {
273                // Arrow points right (at right of tooltip)
274                let tip = pos2(tooltip_rect.right() + size, tooltip_rect.center().y);
275                let base1 = pos2(tooltip_rect.right(), tip.y - size);
276                let base2 = pos2(tooltip_rect.right(), tip.y + size);
277                (tip, base1, base2)
278            }
279            TooltipPosition::Right => {
280                // Arrow points left (at left of tooltip)
281                let tip = pos2(tooltip_rect.left() - size, tooltip_rect.center().y);
282                let base1 = pos2(tooltip_rect.left(), tip.y - size);
283                let base2 = pos2(tooltip_rect.left(), tip.y + size);
284                (tip, base1, base2)
285            }
286        };
287
288        // Draw filled triangle
289        painter.add(Shape::convex_polygon(
290            vec![tip, base1, base2],
291            bg_color,
292            Stroke::NONE,
293        ));
294    }
295}
296
297/// Helper function to show a simple tooltip on any UI element
298///
299/// # Example
300///
301/// ```rust,no_run
302/// # use egui::Ui;
303/// # fn example(ui: &mut Ui) {
304/// use armas_basic::tooltip;
305///
306/// let response = ui.button("Hover me");
307/// tooltip(ui, &response, "This is a tooltip!");
308/// # }
309/// ```
310pub fn tooltip(ui: &mut Ui, response: &Response, text: impl Into<String>) {
311    Tooltip::new(text).show(ui, response);
312}
313
314/// Show tooltip with custom configuration
315///
316/// # Example
317///
318/// ```rust,no_run
319/// # use egui::Ui;
320/// # fn example(ui: &mut Ui) {
321/// use armas_basic::{tooltip_with, TooltipPosition};
322///
323/// let response = ui.button("Hover me");
324/// tooltip_with(ui, &response, "Custom tooltip", |t| {
325///     t.position(TooltipPosition::Bottom).delay(500)
326/// });
327/// # }
328/// ```
329pub fn tooltip_with(
330    ui: &mut Ui,
331    response: &Response,
332    text: impl Into<String>,
333    configure: impl FnOnce(Tooltip) -> Tooltip,
334) {
335    configure(Tooltip::new(text)).show(ui, response);
336}
337
338// Keep these for backwards compatibility but mark as deprecated
339#[doc(hidden)]
340pub type TooltipStyle = ();
341#[doc(hidden)]
342pub type TooltipColor = ();