Skip to main content

revue/widget/feedback/
tooltip.rs

1//! Tooltip widget for displaying contextual information
2//!
3//! Provides hover-style tooltips and help text displays.
4
5use crate::render::{Cell, Modifier};
6use crate::style::Color;
7use crate::utils::border::BorderChars;
8use crate::widget::traits::{RenderContext, View, WidgetProps};
9use crate::{impl_props_builders, impl_styled_view};
10
11/// Tooltip position relative to anchor
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum TooltipPosition {
14    /// Above the anchor
15    #[default]
16    Top,
17    /// Below the anchor
18    Bottom,
19    /// To the left of anchor
20    Left,
21    /// To the right of anchor
22    Right,
23    /// Auto-detect best position
24    Auto,
25}
26
27/// Tooltip arrow style
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum TooltipArrow {
30    /// No arrow
31    #[default]
32    None,
33    /// Simple arrow
34    Simple,
35    /// Unicode arrow
36    Unicode,
37}
38
39impl TooltipArrow {
40    fn chars(&self, position: TooltipPosition) -> (char, char) {
41        match (self, position) {
42            (TooltipArrow::None, _) => (' ', ' '),
43            (TooltipArrow::Simple, TooltipPosition::Top) => ('v', 'v'),
44            (TooltipArrow::Simple, TooltipPosition::Bottom) => ('^', '^'),
45            (TooltipArrow::Simple, TooltipPosition::Left) => ('>', '>'),
46            (TooltipArrow::Simple, TooltipPosition::Right) => ('<', '<'),
47            (TooltipArrow::Simple, TooltipPosition::Auto) => ('v', 'v'),
48            (TooltipArrow::Unicode, TooltipPosition::Top) => ('▼', '▽'),
49            (TooltipArrow::Unicode, TooltipPosition::Bottom) => ('▲', '△'),
50            (TooltipArrow::Unicode, TooltipPosition::Left) => ('▶', '▷'),
51            (TooltipArrow::Unicode, TooltipPosition::Right) => ('◀', '◁'),
52            (TooltipArrow::Unicode, TooltipPosition::Auto) => ('▼', '▽'),
53        }
54    }
55}
56
57/// Tooltip style
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum TooltipStyle {
60    /// Simple text
61    #[default]
62    Plain,
63    /// With border
64    Bordered,
65    /// Rounded corners
66    Rounded,
67    /// Info style (cyan)
68    Info,
69    /// Warning style (yellow)
70    Warning,
71    /// Error style (red)
72    Error,
73    /// Success style (green)
74    Success,
75}
76
77impl TooltipStyle {
78    fn colors(&self) -> (Color, Color) {
79        match self {
80            TooltipStyle::Plain => (Color::WHITE, Color::rgb(40, 40, 40)),
81            TooltipStyle::Bordered => (Color::WHITE, Color::rgb(30, 30, 30)),
82            TooltipStyle::Rounded => (Color::WHITE, Color::rgb(30, 30, 30)),
83            TooltipStyle::Info => (Color::WHITE, Color::rgb(30, 80, 100)),
84            TooltipStyle::Warning => (Color::BLACK, Color::rgb(180, 150, 0)),
85            TooltipStyle::Error => (Color::WHITE, Color::rgb(150, 30, 30)),
86            TooltipStyle::Success => (Color::WHITE, Color::rgb(30, 100, 50)),
87        }
88    }
89
90    fn border_chars(&self) -> Option<BorderChars> {
91        match self {
92            TooltipStyle::Plain => None,
93            TooltipStyle::Bordered
94            | TooltipStyle::Info
95            | TooltipStyle::Warning
96            | TooltipStyle::Error
97            | TooltipStyle::Success => Some(BorderChars::SINGLE),
98            TooltipStyle::Rounded => Some(BorderChars::ROUNDED),
99        }
100    }
101}
102
103/// Tooltip widget
104pub struct Tooltip {
105    /// Tooltip text (supports multiple lines)
106    text: String,
107    /// Position relative to anchor
108    position: TooltipPosition,
109    /// Anchor point (x, y)
110    anchor: (u16, u16),
111    /// Visual style
112    style: TooltipStyle,
113    /// Arrow style
114    arrow: TooltipArrow,
115    /// Max width (0 = auto)
116    max_width: u16,
117    /// Visible
118    visible: bool,
119    /// Custom colors
120    fg: Option<Color>,
121    bg: Option<Color>,
122    /// Title (optional)
123    title: Option<String>,
124    /// Show delay in frames (for animated appearance)
125    delay: u16,
126    /// Current delay counter
127    delay_counter: u16,
128    /// Widget properties
129    props: WidgetProps,
130}
131
132impl Tooltip {
133    /// Create a new tooltip
134    pub fn new(text: impl Into<String>) -> Self {
135        Self {
136            text: text.into(),
137            position: TooltipPosition::Top,
138            anchor: (0, 0),
139            style: TooltipStyle::Bordered,
140            arrow: TooltipArrow::Unicode,
141            max_width: 40,
142            visible: true,
143            fg: None,
144            bg: None,
145            title: None,
146            delay: 0,
147            delay_counter: 0,
148            props: WidgetProps::new(),
149        }
150    }
151
152    /// Set tooltip text
153    pub fn text(mut self, text: impl Into<String>) -> Self {
154        self.text = text.into();
155        self
156    }
157
158    /// Set position
159    pub fn position(mut self, position: TooltipPosition) -> Self {
160        self.position = position;
161        self
162    }
163
164    /// Set anchor point
165    pub fn anchor(mut self, x: u16, y: u16) -> Self {
166        self.anchor = (x, y);
167        self
168    }
169
170    /// Set style
171    pub fn style(mut self, style: TooltipStyle) -> Self {
172        self.style = style;
173        self
174    }
175
176    /// Set arrow style
177    pub fn arrow(mut self, arrow: TooltipArrow) -> Self {
178        self.arrow = arrow;
179        self
180    }
181
182    /// Set max width
183    pub fn max_width(mut self, width: u16) -> Self {
184        self.max_width = width;
185        self
186    }
187
188    /// Set visibility
189    pub fn visible(mut self, visible: bool) -> Self {
190        self.visible = visible;
191        self
192    }
193
194    /// Set foreground color
195    pub fn fg(mut self, color: Color) -> Self {
196        self.fg = Some(color);
197        self
198    }
199
200    /// Set background color
201    pub fn bg(mut self, color: Color) -> Self {
202        self.bg = Some(color);
203        self
204    }
205
206    /// Set title
207    pub fn title(mut self, title: impl Into<String>) -> Self {
208        self.title = Some(title.into());
209        self
210    }
211
212    /// Set show delay
213    pub fn delay(mut self, frames: u16) -> Self {
214        self.delay = frames;
215        self
216    }
217
218    // Preset styles
219
220    /// Create info tooltip
221    pub fn info(text: impl Into<String>) -> Self {
222        Self::new(text).style(TooltipStyle::Info)
223    }
224
225    /// Create warning tooltip
226    pub fn warning(text: impl Into<String>) -> Self {
227        Self::new(text).style(TooltipStyle::Warning)
228    }
229
230    /// Create error tooltip
231    pub fn error(text: impl Into<String>) -> Self {
232        Self::new(text).style(TooltipStyle::Error)
233    }
234
235    /// Create success tooltip
236    pub fn success(text: impl Into<String>) -> Self {
237        Self::new(text).style(TooltipStyle::Success)
238    }
239
240    /// Show the tooltip
241    pub fn show(&mut self) {
242        self.visible = true;
243        self.delay_counter = 0;
244    }
245
246    /// Hide the tooltip
247    pub fn hide(&mut self) {
248        self.visible = false;
249    }
250
251    /// Toggle visibility
252    pub fn toggle(&mut self) {
253        self.visible = !self.visible;
254    }
255
256    /// Check if visible
257    pub fn is_visible(&self) -> bool {
258        self.visible && self.delay_counter >= self.delay
259    }
260
261    /// Tick for delay animation
262    pub fn tick(&mut self) {
263        if self.delay_counter < self.delay {
264            self.delay_counter += 1;
265        }
266    }
267
268    /// Set anchor position
269    pub fn set_anchor(&mut self, x: u16, y: u16) {
270        self.anchor = (x, y);
271    }
272
273    // Getters for testing
274    #[doc(hidden)]
275    pub fn get_text(&self) -> &str {
276        &self.text
277    }
278
279    #[doc(hidden)]
280    pub fn get_position(&self) -> TooltipPosition {
281        self.position
282    }
283
284    #[doc(hidden)]
285    pub fn get_anchor(&self) -> (u16, u16) {
286        self.anchor
287    }
288
289    #[doc(hidden)]
290    pub fn get_style(&self) -> TooltipStyle {
291        self.style
292    }
293
294    #[doc(hidden)]
295    pub fn get_arrow(&self) -> TooltipArrow {
296        self.arrow
297    }
298
299    #[doc(hidden)]
300    pub fn get_max_width(&self) -> u16 {
301        self.max_width
302    }
303
304    #[doc(hidden)]
305    pub fn get_delay(&self) -> u16 {
306        self.delay
307    }
308
309    #[doc(hidden)]
310    pub fn get_delay_counter(&self) -> u16 {
311        self.delay_counter
312    }
313
314    #[doc(hidden)]
315    pub fn get_title(&self) -> Option<&str> {
316        self.title.as_deref()
317    }
318
319    /// Word wrap text
320    fn wrap_text(&self) -> Vec<String> {
321        let max_width = if self.max_width > 0 {
322            self.max_width as usize
323        } else {
324            40
325        };
326
327        let mut lines = Vec::new();
328        for line in self.text.lines() {
329            if line.len() <= max_width {
330                lines.push(line.to_string());
331            } else {
332                // Simple word wrap
333                let mut current_line = String::new();
334                for word in line.split_whitespace() {
335                    if current_line.is_empty() {
336                        current_line = word.to_string();
337                    } else if current_line.len() + 1 + word.len() <= max_width {
338                        current_line.push(' ');
339                        current_line.push_str(word);
340                    } else {
341                        lines.push(current_line);
342                        current_line = word.to_string();
343                    }
344                }
345                if !current_line.is_empty() {
346                    lines.push(current_line);
347                }
348            }
349        }
350
351        if lines.is_empty() {
352            lines.push(String::new());
353        }
354
355        lines
356    }
357
358    /// Calculate tooltip dimensions
359    fn calculate_dimensions(&self) -> (u16, u16) {
360        let lines = self.wrap_text();
361        let has_border = self.style.border_chars().is_some();
362        let has_title = self.title.is_some();
363
364        let content_width = lines.iter().map(|l| l.len()).max().unwrap_or(0) as u16;
365        let title_width = self.title.as_ref().map(|t| t.len() as u16 + 2).unwrap_or(0);
366        let text_width = content_width.max(title_width);
367
368        let width = text_width + if has_border { 4 } else { 2 }; // padding + border
369        let height = lines.len() as u16
370            + if has_border { 2 } else { 0 }
371            + if has_title && has_border { 1 } else { 0 };
372
373        (width, height)
374    }
375
376    /// Calculate position based on anchor and available space
377    fn calculate_position(&self, area_width: u16, area_height: u16) -> (u16, u16, TooltipPosition) {
378        let (tooltip_w, tooltip_h) = self.calculate_dimensions();
379        let (anchor_x, anchor_y) = self.anchor;
380        let arrow_offset: u16 = if matches!(self.arrow, TooltipArrow::None) {
381            0
382        } else {
383            1
384        };
385
386        let (x, y, position) = match self.position {
387            TooltipPosition::Auto => {
388                // Auto-detect best position based on available space
389                let space_above = anchor_y;
390                let space_below = area_height.saturating_sub(anchor_y + 1);
391                let space_left = anchor_x;
392                let space_right = area_width.saturating_sub(anchor_x + 1);
393
394                let pos = if space_above >= tooltip_h + arrow_offset {
395                    TooltipPosition::Top
396                } else if space_below >= tooltip_h + arrow_offset {
397                    TooltipPosition::Bottom
398                } else if space_right >= tooltip_w + arrow_offset {
399                    TooltipPosition::Right
400                } else if space_left >= tooltip_w + arrow_offset {
401                    TooltipPosition::Left
402                } else {
403                    TooltipPosition::Top // Default fallback
404                };
405
406                // Calculate position for the auto-detected position
407                // Note: pos is guaranteed to be Top/Bottom/Left/Right (never Auto)
408                // because Auto was resolved to a concrete position above
409                let (x, y) = match pos {
410                    TooltipPosition::Top => {
411                        let x = anchor_x.saturating_sub(tooltip_w / 2);
412                        let y = anchor_y.saturating_sub(tooltip_h + arrow_offset);
413                        (x, y)
414                    }
415                    TooltipPosition::Bottom => {
416                        let x = anchor_x.saturating_sub(tooltip_w / 2);
417                        let y = anchor_y + 1 + arrow_offset;
418                        (x, y)
419                    }
420                    TooltipPosition::Left => {
421                        let x = anchor_x.saturating_sub(tooltip_w + arrow_offset);
422                        let y = anchor_y.saturating_sub(tooltip_h / 2);
423                        (x, y)
424                    }
425                    TooltipPosition::Right => {
426                        let x = anchor_x + 1 + arrow_offset;
427                        let y = anchor_y.saturating_sub(tooltip_h / 2);
428                        (x, y)
429                    }
430                    // Auto is handled above and never reaches here
431                    TooltipPosition::Auto => {
432                        unreachable!("Auto position resolved to concrete position above")
433                    }
434                };
435                (x, y, pos)
436            }
437            TooltipPosition::Top => {
438                let x = anchor_x.saturating_sub(tooltip_w / 2);
439                let y = anchor_y.saturating_sub(tooltip_h + arrow_offset);
440                (x, y, TooltipPosition::Top)
441            }
442            TooltipPosition::Bottom => {
443                let x = anchor_x.saturating_sub(tooltip_w / 2);
444                let y = anchor_y + 1 + arrow_offset;
445                (x, y, TooltipPosition::Bottom)
446            }
447            TooltipPosition::Left => {
448                let x = anchor_x.saturating_sub(tooltip_w + arrow_offset);
449                let y = anchor_y.saturating_sub(tooltip_h / 2);
450                (x, y, TooltipPosition::Left)
451            }
452            TooltipPosition::Right => {
453                let x = anchor_x + 1 + arrow_offset;
454                let y = anchor_y.saturating_sub(tooltip_h / 2);
455                (x, y, TooltipPosition::Right)
456            }
457        };
458
459        // Clamp to screen bounds
460        let x = x.min(area_width.saturating_sub(tooltip_w));
461        let y = y.min(area_height.saturating_sub(tooltip_h));
462
463        (x, y, position)
464    }
465}
466
467impl Default for Tooltip {
468    fn default() -> Self {
469        Self::new("")
470    }
471}
472
473impl View for Tooltip {
474    crate::impl_view_meta!("Tooltip");
475
476    fn render(&self, ctx: &mut RenderContext) {
477        if !self.visible || (self.delay > 0 && self.delay_counter < self.delay) {
478            return;
479        }
480
481        let area = ctx.area;
482        let (tooltip_w, tooltip_h) = self.calculate_dimensions();
483        let (tooltip_x, tooltip_y, actual_position) =
484            self.calculate_position(area.width, area.height);
485
486        // Get colors
487        let (default_fg, default_bg) = self.style.colors();
488        let fg = self.fg.unwrap_or(default_fg);
489        let bg = self.bg.unwrap_or(default_bg);
490
491        // Draw background
492        for dy in 0..tooltip_h {
493            for dx in 0..tooltip_w {
494                let x = tooltip_x + dx;
495                let y = tooltip_y + dy;
496                if x < area.width && y < area.height {
497                    let mut cell = Cell::new(' ');
498                    cell.bg = Some(bg);
499                    ctx.buffer.set(x, y, cell);
500                }
501            }
502        }
503
504        // Draw border if applicable
505        let content_start_x;
506        let content_start_y;
507
508        if let Some(border) = self.style.border_chars() {
509            content_start_x = tooltip_x + 2;
510            content_start_y = tooltip_y + 1;
511
512            // Top border
513            if tooltip_y < area.height {
514                let mut tl = Cell::new(border.top_left);
515                tl.fg = Some(fg);
516                tl.bg = Some(bg);
517                ctx.buffer.set(tooltip_x, tooltip_y, tl);
518
519                for dx in 1..tooltip_w - 1 {
520                    let mut h = Cell::new(border.horizontal);
521                    h.fg = Some(fg);
522                    h.bg = Some(bg);
523                    ctx.buffer.set(tooltip_x + dx, tooltip_y, h);
524                }
525
526                let mut tr = Cell::new(border.top_right);
527                tr.fg = Some(fg);
528                tr.bg = Some(bg);
529                ctx.buffer.set(tooltip_x + tooltip_w - 1, tooltip_y, tr);
530            }
531
532            // Title if present
533            if let Some(ref title) = self.title {
534                let title_x = tooltip_x + 2;
535                let title_y = tooltip_y + 1;
536                for (i, ch) in title.chars().enumerate() {
537                    let x = title_x + i as u16;
538                    if x < tooltip_x + tooltip_w - 2 {
539                        let mut cell = Cell::new(ch);
540                        cell.fg = Some(fg);
541                        cell.bg = Some(bg);
542                        cell.modifier |= Modifier::BOLD;
543                        ctx.buffer.set(x, title_y, cell);
544                    }
545                }
546            }
547
548            // Left and right borders
549            let _text_start_y = if self.title.is_some() {
550                tooltip_y + 2
551            } else {
552                tooltip_y + 1
553            };
554            for dy in 1..tooltip_h - 1 {
555                let y = tooltip_y + dy;
556                if y < area.height {
557                    let mut left = Cell::new(border.vertical);
558                    left.fg = Some(fg);
559                    left.bg = Some(bg);
560                    ctx.buffer.set(tooltip_x, y, left);
561
562                    let mut right = Cell::new(border.vertical);
563                    right.fg = Some(fg);
564                    right.bg = Some(bg);
565                    ctx.buffer.set(tooltip_x + tooltip_w - 1, y, right);
566                }
567            }
568
569            // Bottom border
570            let bottom_y = tooltip_y + tooltip_h - 1;
571            if bottom_y < area.height {
572                let mut bl = Cell::new(border.bottom_left);
573                bl.fg = Some(fg);
574                bl.bg = Some(bg);
575                ctx.buffer.set(tooltip_x, bottom_y, bl);
576
577                for dx in 1..tooltip_w - 1 {
578                    let mut h = Cell::new(border.horizontal);
579                    h.fg = Some(fg);
580                    h.bg = Some(bg);
581                    ctx.buffer.set(tooltip_x + dx, bottom_y, h);
582                }
583
584                let mut br = Cell::new(border.bottom_right);
585                br.fg = Some(fg);
586                br.bg = Some(bg);
587                ctx.buffer.set(tooltip_x + tooltip_w - 1, bottom_y, br);
588            }
589        } else {
590            content_start_x = tooltip_x + 1;
591            content_start_y = tooltip_y;
592        }
593
594        // Draw text content
595        let lines = self.wrap_text();
596        let text_y_offset = if self.title.is_some() && self.style.border_chars().is_some() {
597            1
598        } else {
599            0
600        };
601
602        for (i, line) in lines.iter().enumerate() {
603            let y = content_start_y + text_y_offset + i as u16;
604            if y >= area.height || y >= tooltip_y + tooltip_h - 1 {
605                break;
606            }
607
608            for (j, ch) in line.chars().enumerate() {
609                let x = content_start_x + j as u16;
610                if x < tooltip_x + tooltip_w - 1 {
611                    let mut cell = Cell::new(ch);
612                    cell.fg = Some(fg);
613                    cell.bg = Some(bg);
614                    ctx.buffer.set(x, y, cell);
615                }
616            }
617        }
618
619        // Draw arrow
620        if !matches!(self.arrow, TooltipArrow::None) {
621            let (arrow_char, _) = self.arrow.chars(actual_position);
622            let (arrow_x, arrow_y) = match actual_position {
623                TooltipPosition::Top => (self.anchor.0, tooltip_y + tooltip_h),
624                TooltipPosition::Bottom => (self.anchor.0, tooltip_y.saturating_sub(1)),
625                TooltipPosition::Left => (tooltip_x + tooltip_w, self.anchor.1),
626                TooltipPosition::Right => (tooltip_x.saturating_sub(1), self.anchor.1),
627                // Auto is already resolved to a concrete position in calculate_position
628                // This case should never be reached, but use Top as fallback
629                TooltipPosition::Auto => (self.anchor.0, tooltip_y + tooltip_h),
630            };
631
632            if arrow_x < area.width && arrow_y < area.height {
633                let mut cell = Cell::new(arrow_char);
634                cell.fg = Some(fg);
635                ctx.buffer.set(arrow_x, arrow_y, cell);
636            }
637        }
638    }
639}
640
641impl_styled_view!(Tooltip);
642impl_props_builders!(Tooltip);
643
644/// Helper to create a tooltip
645pub fn tooltip(text: impl Into<String>) -> Tooltip {
646    Tooltip::new(text)
647}
648
649// KEEP HERE - accesses private fields
650// Tests for private methods that cannot be extracted
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    // These tests access private methods and must stay inline
657
658    #[test]
659    fn test_tooltip_wrap_text() {
660        let t = Tooltip::new("This is a very long text that should be wrapped").max_width(20);
661        let lines = t.wrap_text();
662        assert!(lines.len() > 1);
663        assert!(lines.iter().all(|l| l.len() <= 20));
664    }
665
666    #[test]
667    fn test_tooltip_calculate_dimensions() {
668        let t = Tooltip::new("Short").style(TooltipStyle::Bordered);
669        let (w, h) = t.calculate_dimensions();
670        assert!(w > 5);
671        assert!(h >= 3); // At least border + 1 line
672    }
673
674    #[test]
675    fn test_tooltip_with_title() {
676        let t = Tooltip::new("Content")
677            .title("Title")
678            .style(TooltipStyle::Bordered);
679
680        let (_, h) = t.calculate_dimensions();
681        assert!(h >= 4); // border + title + content
682    }
683
684    #[test]
685    fn test_tooltip_auto_position() {
686        let t = Tooltip::new("Test")
687            .position(TooltipPosition::Auto)
688            .anchor(5, 5);
689
690        let (_, _, pos) = t.calculate_position(40, 20);
691        // Should choose a valid position
692        assert!(!matches!(pos, TooltipPosition::Auto));
693    }
694}