use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
use super::{Component, RenderContext, Toggleable};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum TooltipPosition {
Above,
#[default]
Below,
Left,
Right,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TooltipMessage {
Show,
Hide,
Toggle,
SetContent(String),
SetPosition(TooltipPosition),
Tick(u64),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TooltipOutput {
Shown,
Hidden,
Expired,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct TooltipState {
content: String,
title: Option<String>,
position: TooltipPosition,
visible: bool,
duration_ms: Option<u64>,
remaining_ms: Option<u64>,
fg_color: Color,
bg_color: Color,
border_color: Color,
}
impl Default for TooltipState {
fn default() -> Self {
Self {
content: String::new(),
title: None,
position: TooltipPosition::Below,
visible: false,
duration_ms: None,
remaining_ms: None,
fg_color: Color::White,
bg_color: Color::Black,
border_color: Color::Gray,
}
}
}
impl TooltipState {
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
..Self::default()
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_position(mut self, position: TooltipPosition) -> Self {
self.position = position;
self
}
pub fn with_duration(mut self, duration_ms: u64) -> Self {
self.duration_ms = Some(duration_ms);
self
}
pub fn with_fg_color(mut self, color: Color) -> Self {
self.fg_color = color;
self
}
pub fn with_bg_color(mut self, color: Color) -> Self {
self.bg_color = color;
self
}
pub fn with_border_color(mut self, color: Color) -> Self {
self.border_color = color;
self
}
pub fn content(&self) -> &str {
&self.content
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn position(&self) -> TooltipPosition {
self.position
}
pub fn is_visible(&self) -> bool {
self.visible
}
pub fn duration_ms(&self) -> Option<u64> {
self.duration_ms
}
pub fn duration(&self) -> Option<u64> {
self.duration_ms
}
pub fn remaining_ms(&self) -> Option<u64> {
self.remaining_ms
}
pub fn fg_color(&self) -> Color {
self.fg_color
}
pub fn bg_color(&self) -> Color {
self.bg_color
}
pub fn border_color(&self) -> Color {
self.border_color
}
pub fn set_content(&mut self, content: impl Into<String>) {
self.content = content.into();
}
pub fn set_title(&mut self, title: Option<String>) {
self.title = title;
}
pub fn set_position(&mut self, position: TooltipPosition) {
self.position = position;
}
pub fn set_duration(&mut self, duration_ms: Option<u64>) {
self.duration_ms = duration_ms;
}
pub fn set_fg_color(&mut self, color: Color) {
self.fg_color = color;
}
pub fn set_bg_color(&mut self, color: Color) {
self.bg_color = color;
}
pub fn set_border_color(&mut self, color: Color) {
self.border_color = color;
}
pub fn set_visible(&mut self, visible: bool) {
Tooltip::set_visible(self, visible);
}
pub fn with_visible(mut self, visible: bool) -> Self {
Tooltip::set_visible(&mut self, visible);
self
}
}
pub struct Tooltip;
impl Component for Tooltip {
type State = TooltipState;
type Message = TooltipMessage;
type Output = TooltipOutput;
fn init() -> Self::State {
TooltipState::default()
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
TooltipMessage::Show => {
if !state.visible {
state.visible = true;
state.remaining_ms = state.duration_ms;
Some(TooltipOutput::Shown)
} else {
None
}
}
TooltipMessage::Hide => {
if state.visible {
state.visible = false;
state.remaining_ms = None;
Some(TooltipOutput::Hidden)
} else {
None
}
}
TooltipMessage::Toggle => {
if state.visible {
state.visible = false;
state.remaining_ms = None;
Some(TooltipOutput::Hidden)
} else {
state.visible = true;
state.remaining_ms = state.duration_ms;
Some(TooltipOutput::Shown)
}
}
TooltipMessage::SetContent(content) => {
state.content = content;
None
}
TooltipMessage::SetPosition(position) => {
state.position = position;
None
}
TooltipMessage::Tick(elapsed) => {
if !state.visible {
return None;
}
if let Some(remaining) = state.remaining_ms {
if elapsed >= remaining {
state.visible = false;
state.remaining_ms = None;
Some(TooltipOutput::Expired)
} else {
state.remaining_ms = Some(remaining - elapsed);
None
}
} else {
None
}
}
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
if state.visible {
crate::annotation::with_registry(|reg| {
reg.register(
ctx.area,
crate::annotation::Annotation::tooltip("tooltip")
.with_label(state.content.as_str()),
);
});
}
Self::view_at(state, ctx.frame, ctx.area, ctx.area);
}
}
impl Toggleable for Tooltip {
fn is_visible(state: &Self::State) -> bool {
state.visible
}
fn set_visible(state: &mut Self::State, visible: bool) {
state.visible = visible;
if visible {
state.remaining_ms = state.duration_ms;
} else {
state.remaining_ms = None;
}
}
}
impl Tooltip {
pub fn view_at(state: &TooltipState, frame: &mut Frame, target: Rect, bounds: Rect) {
if !state.visible || state.content.is_empty() {
return;
}
let area = calculate_tooltip_area(state, target, bounds);
frame.render_widget(Clear, area);
let mut block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(state.border_color))
.style(Style::default().bg(state.bg_color));
if let Some(title) = &state.title {
block = block.title(format!(" {} ", title));
}
let paragraph = Paragraph::new(state.content.as_str())
.style(Style::default().fg(state.fg_color).bg(state.bg_color))
.block(block)
.wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
}
}
fn calculate_tooltip_area(state: &TooltipState, target: Rect, bounds: Rect) -> Rect {
let content_width = state.content.lines().map(|l| l.len()).max().unwrap_or(0) as u16 + 2;
let title_width = state
.title
.as_ref()
.map(|t| t.len() as u16 + 4) .unwrap_or(0);
let width = content_width
.max(title_width)
.min(bounds.width.saturating_sub(2))
.max(3);
let content_height = state.content.lines().count().max(1) as u16 + 2; let height = content_height.min(bounds.height.saturating_sub(2)).max(3);
match state.position {
TooltipPosition::Below => {
let y = target.bottom();
if y + height <= bounds.bottom() {
let x = clamp_x(target.x, width, bounds);
Rect::new(x, y, width, height)
} else {
let y = target.y.saturating_sub(height);
let x = clamp_x(target.x, width, bounds);
Rect::new(x, y, width, height)
}
}
TooltipPosition::Above => {
if target.y >= height {
let y = target.y.saturating_sub(height);
let x = clamp_x(target.x, width, bounds);
Rect::new(x, y, width, height)
} else {
let y = target.bottom();
let x = clamp_x(target.x, width, bounds);
Rect::new(x, y, width, height)
}
}
TooltipPosition::Right => {
let x = target.right();
if x + width <= bounds.right() {
let y = clamp_y(target.y, height, bounds);
Rect::new(x, y, width, height)
} else {
let x = target.x.saturating_sub(width);
let y = clamp_y(target.y, height, bounds);
Rect::new(x, y, width, height)
}
}
TooltipPosition::Left => {
if target.x >= width {
let x = target.x.saturating_sub(width);
let y = clamp_y(target.y, height, bounds);
Rect::new(x, y, width, height)
} else {
let x = target.right();
let y = clamp_y(target.y, height, bounds);
Rect::new(x, y, width, height)
}
}
}
}
fn clamp_x(x: u16, width: u16, bounds: Rect) -> u16 {
let max_x = bounds.right().saturating_sub(width);
x.clamp(bounds.x, max_x)
}
fn clamp_y(y: u16, height: u16, bounds: Rect) -> u16 {
let max_y = bounds.bottom().saturating_sub(height);
y.clamp(bounds.y, max_y)
}
#[cfg(test)]
mod tests;