use crate::render::{Cell, Modifier};
use crate::style::Color;
use crate::utils::border::BorderChars;
use crate::widget::theme::{DARK_BG, EDITOR_BG};
use crate::widget::traits::{RenderContext, View, WidgetProps};
use crate::{impl_props_builders, impl_styled_view};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TooltipPosition {
#[default]
Top,
Bottom,
Left,
Right,
Auto,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TooltipArrow {
#[default]
None,
Simple,
Unicode,
}
impl TooltipArrow {
fn chars(&self, position: TooltipPosition) -> (char, char) {
match (self, position) {
(TooltipArrow::None, _) => (' ', ' '),
(TooltipArrow::Simple, TooltipPosition::Top) => ('v', 'v'),
(TooltipArrow::Simple, TooltipPosition::Bottom) => ('^', '^'),
(TooltipArrow::Simple, TooltipPosition::Left) => ('>', '>'),
(TooltipArrow::Simple, TooltipPosition::Right) => ('<', '<'),
(TooltipArrow::Simple, TooltipPosition::Auto) => ('v', 'v'),
(TooltipArrow::Unicode, TooltipPosition::Top) => ('▼', '▽'),
(TooltipArrow::Unicode, TooltipPosition::Bottom) => ('▲', '△'),
(TooltipArrow::Unicode, TooltipPosition::Left) => ('▶', '▷'),
(TooltipArrow::Unicode, TooltipPosition::Right) => ('◀', '◁'),
(TooltipArrow::Unicode, TooltipPosition::Auto) => ('▼', '▽'),
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum TooltipStyle {
#[default]
Plain,
Bordered,
Rounded,
Info,
Warning,
Error,
Success,
}
impl TooltipStyle {
fn colors(&self) -> (Color, Color) {
match self {
TooltipStyle::Plain => (Color::WHITE, DARK_BG),
TooltipStyle::Bordered => (Color::WHITE, EDITOR_BG),
TooltipStyle::Rounded => (Color::WHITE, EDITOR_BG),
TooltipStyle::Info => (Color::WHITE, Color::rgb(30, 80, 100)),
TooltipStyle::Warning => (Color::BLACK, Color::rgb(180, 150, 0)),
TooltipStyle::Error => (Color::WHITE, Color::rgb(150, 30, 30)),
TooltipStyle::Success => (Color::WHITE, Color::rgb(30, 100, 50)),
}
}
fn border_chars(&self) -> Option<BorderChars> {
match self {
TooltipStyle::Plain => None,
TooltipStyle::Bordered
| TooltipStyle::Info
| TooltipStyle::Warning
| TooltipStyle::Error
| TooltipStyle::Success => Some(BorderChars::SINGLE),
TooltipStyle::Rounded => Some(BorderChars::ROUNDED),
}
}
}
pub struct Tooltip {
text: String,
position: TooltipPosition,
anchor: (u16, u16),
style: TooltipStyle,
arrow: TooltipArrow,
max_width: u16,
visible: bool,
fg: Option<Color>,
bg: Option<Color>,
title: Option<String>,
delay: u16,
delay_counter: u16,
props: WidgetProps,
}
impl Tooltip {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
position: TooltipPosition::Top,
anchor: (0, 0),
style: TooltipStyle::Bordered,
arrow: TooltipArrow::Unicode,
max_width: 40,
visible: true,
fg: None,
bg: None,
title: None,
delay: 0,
delay_counter: 0,
props: WidgetProps::new(),
}
}
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
pub fn position(mut self, position: TooltipPosition) -> Self {
self.position = position;
self
}
pub fn anchor(mut self, x: u16, y: u16) -> Self {
self.anchor = (x, y);
self
}
pub fn style(mut self, style: TooltipStyle) -> Self {
self.style = style;
self
}
pub fn arrow(mut self, arrow: TooltipArrow) -> Self {
self.arrow = arrow;
self
}
pub fn max_width(mut self, width: u16) -> Self {
self.max_width = width;
self
}
pub fn visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
pub fn fg(mut self, color: Color) -> Self {
self.fg = Some(color);
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn delay(mut self, frames: u16) -> Self {
self.delay = frames;
self
}
pub fn info(text: impl Into<String>) -> Self {
Self::new(text).style(TooltipStyle::Info)
}
pub fn warning(text: impl Into<String>) -> Self {
Self::new(text).style(TooltipStyle::Warning)
}
pub fn error(text: impl Into<String>) -> Self {
Self::new(text).style(TooltipStyle::Error)
}
pub fn success(text: impl Into<String>) -> Self {
Self::new(text).style(TooltipStyle::Success)
}
pub fn show(&mut self) {
self.visible = true;
self.delay_counter = 0;
}
pub fn hide(&mut self) {
self.visible = false;
}
pub fn toggle(&mut self) {
self.visible = !self.visible;
}
pub fn is_visible(&self) -> bool {
self.visible && self.delay_counter >= self.delay
}
pub fn tick(&mut self) {
if self.delay_counter < self.delay {
self.delay_counter += 1;
}
}
pub fn set_anchor(&mut self, x: u16, y: u16) {
self.anchor = (x, y);
}
#[doc(hidden)]
pub fn get_text(&self) -> &str {
&self.text
}
#[doc(hidden)]
pub fn get_position(&self) -> TooltipPosition {
self.position
}
#[doc(hidden)]
pub fn get_anchor(&self) -> (u16, u16) {
self.anchor
}
#[doc(hidden)]
pub fn get_style(&self) -> TooltipStyle {
self.style
}
#[doc(hidden)]
pub fn get_arrow(&self) -> TooltipArrow {
self.arrow
}
#[doc(hidden)]
pub fn get_max_width(&self) -> u16 {
self.max_width
}
#[doc(hidden)]
pub fn get_delay(&self) -> u16 {
self.delay
}
#[doc(hidden)]
pub fn get_delay_counter(&self) -> u16 {
self.delay_counter
}
#[doc(hidden)]
pub fn get_title(&self) -> Option<&str> {
self.title.as_deref()
}
fn wrap_text(&self) -> Vec<String> {
let max_width = if self.max_width > 0 {
self.max_width as usize
} else {
40
};
let mut lines = Vec::new();
for line in self.text.lines() {
if line.len() <= max_width {
lines.push(line.to_string());
} else {
let mut current_line = String::new();
for word in line.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_width {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
}
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn calculate_dimensions(&self) -> (u16, u16) {
let lines = self.wrap_text();
let has_border = self.style.border_chars().is_some();
let has_title = self.title.is_some();
let content_width = lines
.iter()
.map(|l| crate::utils::display_width(l))
.max()
.unwrap_or(0) as u16;
let title_width = self
.title
.as_ref()
.map(|t| crate::utils::display_width(t) as u16 + 2)
.unwrap_or(0);
let text_width = content_width.max(title_width);
let width = text_width + if has_border { 4 } else { 2 }; let height = lines.len() as u16
+ if has_border { 2 } else { 0 }
+ if has_title && has_border { 1 } else { 0 };
(width, height)
}
fn calculate_position(&self, area_width: u16, area_height: u16) -> (u16, u16, TooltipPosition) {
let (tooltip_w, tooltip_h) = self.calculate_dimensions();
let (anchor_x, anchor_y) = self.anchor;
let arrow_offset: u16 = if matches!(self.arrow, TooltipArrow::None) {
0
} else {
1
};
let (x, y, position) = match self.position {
TooltipPosition::Auto => {
let space_above = anchor_y;
let space_below = area_height.saturating_sub(anchor_y + 1);
let space_left = anchor_x;
let space_right = area_width.saturating_sub(anchor_x + 1);
let pos = if space_above >= tooltip_h + arrow_offset {
TooltipPosition::Top
} else if space_below >= tooltip_h + arrow_offset {
TooltipPosition::Bottom
} else if space_right >= tooltip_w + arrow_offset {
TooltipPosition::Right
} else if space_left >= tooltip_w + arrow_offset {
TooltipPosition::Left
} else {
TooltipPosition::Top };
let (x, y) = match pos {
TooltipPosition::Top => {
let x = anchor_x.saturating_sub(tooltip_w / 2);
let y = anchor_y.saturating_sub(tooltip_h + arrow_offset);
(x, y)
}
TooltipPosition::Bottom => {
let x = anchor_x.saturating_sub(tooltip_w / 2);
let y = anchor_y + 1 + arrow_offset;
(x, y)
}
TooltipPosition::Left => {
let x = anchor_x.saturating_sub(tooltip_w + arrow_offset);
let y = anchor_y.saturating_sub(tooltip_h / 2);
(x, y)
}
TooltipPosition::Right => {
let x = anchor_x + 1 + arrow_offset;
let y = anchor_y.saturating_sub(tooltip_h / 2);
(x, y)
}
TooltipPosition::Auto => {
unreachable!("Auto position resolved to concrete position above")
}
};
(x, y, pos)
}
TooltipPosition::Top => {
let x = anchor_x.saturating_sub(tooltip_w / 2);
let y = anchor_y.saturating_sub(tooltip_h + arrow_offset);
(x, y, TooltipPosition::Top)
}
TooltipPosition::Bottom => {
let x = anchor_x.saturating_sub(tooltip_w / 2);
let y = anchor_y + 1 + arrow_offset;
(x, y, TooltipPosition::Bottom)
}
TooltipPosition::Left => {
let x = anchor_x.saturating_sub(tooltip_w + arrow_offset);
let y = anchor_y.saturating_sub(tooltip_h / 2);
(x, y, TooltipPosition::Left)
}
TooltipPosition::Right => {
let x = anchor_x + 1 + arrow_offset;
let y = anchor_y.saturating_sub(tooltip_h / 2);
(x, y, TooltipPosition::Right)
}
};
let x = x.min(area_width.saturating_sub(tooltip_w));
let y = y.min(area_height.saturating_sub(tooltip_h));
(x, y, position)
}
}
impl Default for Tooltip {
fn default() -> Self {
Self::new("")
}
}
impl View for Tooltip {
crate::impl_view_meta!("Tooltip");
fn render(&self, ctx: &mut RenderContext) {
if !self.visible || (self.delay > 0 && self.delay_counter < self.delay) {
return;
}
let area = ctx.area;
let (tooltip_w, tooltip_h) = self.calculate_dimensions();
let (tooltip_x, tooltip_y, actual_position) =
self.calculate_position(area.width, area.height);
let (default_fg, default_bg) = self.style.colors();
let fg = self.fg.unwrap_or(default_fg);
let bg = self.bg.unwrap_or(default_bg);
let overlay_area = crate::layout::Rect::new(tooltip_x, tooltip_y, tooltip_w, tooltip_h);
let mut entry = crate::widget::traits::OverlayEntry::new(150, overlay_area);
let cell_with = |ch: char, cell_fg: Color, cell_bg: Color| -> Cell {
let mut c = Cell::new(ch);
c.fg = Some(cell_fg);
c.bg = Some(cell_bg);
c
};
for dy in 0..tooltip_h {
for dx in 0..tooltip_w {
entry.push(dx, dy, cell_with(' ', fg, bg));
}
}
let content_rx;
let content_ry;
if let Some(border) = self.style.border_chars() {
content_rx = 2u16;
content_ry = 1u16;
entry.push(0, 0, cell_with(border.top_left, fg, bg));
for dx in 1..tooltip_w.saturating_sub(1) {
entry.push(dx, 0, cell_with(border.horizontal, fg, bg));
}
entry.push(
tooltip_w.saturating_sub(1),
0,
cell_with(border.top_right, fg, bg),
);
if let Some(ref title) = self.title {
for (i, ch) in title.chars().enumerate() {
let rx = 2 + i as u16;
if rx < tooltip_w.saturating_sub(2) {
let mut c = cell_with(ch, fg, bg);
c.modifier |= Modifier::BOLD;
entry.push(rx, 1, c);
}
}
}
for dy in 1..tooltip_h.saturating_sub(1) {
entry.push(0, dy, cell_with(border.vertical, fg, bg));
entry.push(
tooltip_w.saturating_sub(1),
dy,
cell_with(border.vertical, fg, bg),
);
}
let by = tooltip_h.saturating_sub(1);
entry.push(0, by, cell_with(border.bottom_left, fg, bg));
for dx in 1..tooltip_w.saturating_sub(1) {
entry.push(dx, by, cell_with(border.horizontal, fg, bg));
}
entry.push(
tooltip_w.saturating_sub(1),
by,
cell_with(border.bottom_right, fg, bg),
);
} else {
content_rx = 1;
content_ry = 0;
}
let lines = self.wrap_text();
let text_y_off = if self.title.is_some() && self.style.border_chars().is_some() {
1u16
} else {
0
};
for (i, line) in lines.iter().enumerate() {
let ry = content_ry + text_y_off + i as u16;
if ry >= tooltip_h.saturating_sub(1) {
break;
}
for (j, ch) in line.chars().enumerate() {
let rx = content_rx + j as u16;
if rx < tooltip_w.saturating_sub(1) {
entry.push(rx, ry, cell_with(ch, fg, bg));
}
}
}
if !matches!(self.arrow, TooltipArrow::None) {
let (arrow_char, _) = self.arrow.chars(actual_position);
let (arrow_abs_x, arrow_abs_y) = match actual_position {
TooltipPosition::Top => (self.anchor.0, tooltip_y + tooltip_h),
TooltipPosition::Bottom => (self.anchor.0, tooltip_y.saturating_sub(1)),
TooltipPosition::Left => (tooltip_x + tooltip_w, self.anchor.1),
TooltipPosition::Right => (tooltip_x.saturating_sub(1), self.anchor.1),
TooltipPosition::Auto => (self.anchor.0, tooltip_y + tooltip_h),
};
let inside = arrow_abs_x >= tooltip_x
&& arrow_abs_x < tooltip_x + tooltip_w
&& arrow_abs_y >= tooltip_y
&& arrow_abs_y < tooltip_y + tooltip_h;
let buf_w = ctx.buffer.width();
let buf_h = ctx.buffer.height();
if !inside && arrow_abs_x < buf_w && arrow_abs_y < buf_h {
let arrow_area = crate::layout::Rect::new(arrow_abs_x, arrow_abs_y, 1, 1);
let mut arrow_entry = crate::widget::traits::OverlayEntry::new(151, arrow_area);
let mut cell = Cell::new(arrow_char);
cell.fg = Some(fg);
arrow_entry.push(0, 0, cell);
ctx.queue_overlay(arrow_entry);
}
}
if !ctx.queue_overlay(entry.clone()) {
for oc in &entry.cells {
ctx.set(tooltip_x + oc.x, tooltip_y + oc.y, oc.cell);
}
}
}
}
impl_styled_view!(Tooltip);
impl_props_builders!(Tooltip);
pub fn tooltip(text: impl Into<String>) -> Tooltip {
Tooltip::new(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tooltip_wrap_text() {
let t = Tooltip::new("This is a very long text that should be wrapped").max_width(20);
let lines = t.wrap_text();
assert!(lines.len() > 1);
assert!(lines.iter().all(|l| l.len() <= 20));
}
#[test]
fn test_tooltip_calculate_dimensions() {
let t = Tooltip::new("Short").style(TooltipStyle::Bordered);
let (w, h) = t.calculate_dimensions();
assert!(w > 5);
assert!(h >= 3); }
#[test]
fn test_tooltip_with_title() {
let t = Tooltip::new("Content")
.title("Title")
.style(TooltipStyle::Bordered);
let (_, h) = t.calculate_dimensions();
assert!(h >= 4); }
#[test]
fn test_tooltip_auto_position() {
let t = Tooltip::new("Test")
.position(TooltipPosition::Auto)
.anchor(5, 5);
let (_, _, pos) = t.calculate_position(40, 20);
assert!(!matches!(pos, TooltipPosition::Auto));
}
}