1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13pub enum TooltipPosition {
14 #[default]
16 Top,
17 Bottom,
19 Left,
21 Right,
23 Auto,
25}
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub enum TooltipArrow {
30 #[default]
32 None,
33 Simple,
35 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
59pub enum TooltipStyle {
60 #[default]
62 Plain,
63 Bordered,
65 Rounded,
67 Info,
69 Warning,
71 Error,
73 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
103pub struct Tooltip {
105 text: String,
107 position: TooltipPosition,
109 anchor: (u16, u16),
111 style: TooltipStyle,
113 arrow: TooltipArrow,
115 max_width: u16,
117 visible: bool,
119 fg: Option<Color>,
121 bg: Option<Color>,
122 title: Option<String>,
124 delay: u16,
126 delay_counter: u16,
128 props: WidgetProps,
130}
131
132impl Tooltip {
133 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 pub fn text(mut self, text: impl Into<String>) -> Self {
154 self.text = text.into();
155 self
156 }
157
158 pub fn position(mut self, position: TooltipPosition) -> Self {
160 self.position = position;
161 self
162 }
163
164 pub fn anchor(mut self, x: u16, y: u16) -> Self {
166 self.anchor = (x, y);
167 self
168 }
169
170 pub fn style(mut self, style: TooltipStyle) -> Self {
172 self.style = style;
173 self
174 }
175
176 pub fn arrow(mut self, arrow: TooltipArrow) -> Self {
178 self.arrow = arrow;
179 self
180 }
181
182 pub fn max_width(mut self, width: u16) -> Self {
184 self.max_width = width;
185 self
186 }
187
188 pub fn visible(mut self, visible: bool) -> Self {
190 self.visible = visible;
191 self
192 }
193
194 pub fn fg(mut self, color: Color) -> Self {
196 self.fg = Some(color);
197 self
198 }
199
200 pub fn bg(mut self, color: Color) -> Self {
202 self.bg = Some(color);
203 self
204 }
205
206 pub fn title(mut self, title: impl Into<String>) -> Self {
208 self.title = Some(title.into());
209 self
210 }
211
212 pub fn delay(mut self, frames: u16) -> Self {
214 self.delay = frames;
215 self
216 }
217
218 pub fn info(text: impl Into<String>) -> Self {
222 Self::new(text).style(TooltipStyle::Info)
223 }
224
225 pub fn warning(text: impl Into<String>) -> Self {
227 Self::new(text).style(TooltipStyle::Warning)
228 }
229
230 pub fn error(text: impl Into<String>) -> Self {
232 Self::new(text).style(TooltipStyle::Error)
233 }
234
235 pub fn success(text: impl Into<String>) -> Self {
237 Self::new(text).style(TooltipStyle::Success)
238 }
239
240 pub fn show(&mut self) {
242 self.visible = true;
243 self.delay_counter = 0;
244 }
245
246 pub fn hide(&mut self) {
248 self.visible = false;
249 }
250
251 pub fn toggle(&mut self) {
253 self.visible = !self.visible;
254 }
255
256 pub fn is_visible(&self) -> bool {
258 self.visible && self.delay_counter >= self.delay
259 }
260
261 pub fn tick(&mut self) {
263 if self.delay_counter < self.delay {
264 self.delay_counter += 1;
265 }
266 }
267
268 pub fn set_anchor(&mut self, x: u16, y: u16) {
270 self.anchor = (x, y);
271 }
272
273 #[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 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 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 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 }; 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 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 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 };
405
406 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 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 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 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 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 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 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 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 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 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 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 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 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
644pub fn tooltip(text: impl Into<String>) -> Tooltip {
646 Tooltip::new(text)
647}
648
649#[cfg(test)]
653mod tests {
654 use super::*;
655
656 #[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); }
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); }
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 assert!(!matches!(pos, TooltipPosition::Auto));
693 }
694}