use ratatui::buffer::Buffer;
use super::{ratatui_color, set_cell};
use crate::event::Rect as QRect;
use crate::primitives::tooltip::{Tooltip, TooltipBorder, TooltipChrome, TooltipLayout};
use crate::theme::Theme;
use crate::types::Color;
fn qc(c: Color) -> ratatui::style::Color {
ratatui_color(c)
}
pub fn painted_bounds(layout: &TooltipLayout) -> QRect {
QRect::new(
layout.bounds.x.round(),
layout.bounds.y.round(),
layout.bounds.width.round(),
layout.bounds.height.round(),
)
}
pub fn draw_tooltip(buf: &mut Buffer, tooltip: &Tooltip, layout: &TooltipLayout, theme: &Theme) {
draw_tooltip_with_chrome(buf, tooltip, layout, &TooltipChrome::default(), theme);
}
pub fn draw_tooltip_with_chrome(
buf: &mut Buffer,
tooltip: &Tooltip,
layout: &TooltipLayout,
chrome: &TooltipChrome,
theme: &Theme,
) {
let painted = painted_bounds(layout);
let x = painted.x as u16;
let y = painted.y as u16;
let w = painted.width as u16;
let h = painted.height as u16;
if w == 0 || h == 0 {
return;
}
let fg = tooltip
.fg
.map(qc)
.unwrap_or_else(|| ratatui_color(theme.hover_fg));
let bg = tooltip
.bg
.map(qc)
.unwrap_or_else(|| ratatui_color(theme.hover_bg));
let border = ratatui_color(theme.hover_border);
let has_horizontal_border = matches!(chrome.border, TooltipBorder::Full) && h >= 3 && w >= 2;
let draw_side_bars = !matches!(chrome.border, TooltipBorder::None);
let content_row0: u16 = if has_horizontal_border { 1 } else { 0 };
let content_rows: u16 = if has_horizontal_border { h - 2 } else { h };
let text_col_offset: u16 = if draw_side_bars { 2 } else { 1 };
if has_horizontal_border {
let paint_border_row = |buf: &mut Buffer, row: u16, top: bool, title: Option<&str>| {
let interior = w.saturating_sub(2);
let framed_title = title.and_then(|t| {
let framed = format!(" {t} ");
let fits = framed.chars().count() as u16 <= interior;
fits.then_some(framed)
});
let title_chars: Vec<char> = framed_title
.as_deref()
.map(|s| s.chars().collect())
.unwrap_or_default();
let title_len = title_chars.len() as u16;
let title_start = 1 + interior.saturating_sub(title_len) / 2;
for col in 0..w {
if title_len > 0 && col >= title_start && col < title_start + title_len {
set_cell(
buf,
x + col,
row,
title_chars[(col - title_start) as usize],
fg,
bg,
);
continue;
}
let ch = if col == 0 {
if top {
'┌'
} else {
'└'
}
} else if col == w - 1 {
if top {
'┐'
} else {
'┘'
}
} else {
'─'
};
set_cell(buf, x + col, row, ch, border, bg);
}
};
paint_border_row(buf, y, true, chrome.title.as_deref());
paint_border_row(buf, y + h - 1, false, None);
}
let paint_row_background = |buf: &mut Buffer, row: u16| {
for col in 0..w {
let is_border_col = draw_side_bars && (col == 0 || col == w - 1);
let ch = if is_border_col { '│' } else { ' ' };
let cell_fg = if is_border_col { border } else { fg };
set_cell(buf, x + col, row, ch, cell_fg, bg);
}
};
if let Some(ref styled_lines) = tooltip.styled_lines {
for (i, styled) in styled_lines.iter().enumerate().take(content_rows as usize) {
let row = y + content_row0 + i as u16;
paint_row_background(buf, row);
let mut col_off: u16 = text_col_offset;
for span in &styled.spans {
let span_fg = span.fg.map(qc).unwrap_or(fg);
let span_bg = span.bg.map(qc).unwrap_or(bg);
for ch in span.text.chars() {
let col = x + col_off;
if col + 1 >= x + w {
break;
}
set_cell(buf, col, row, ch, span_fg, span_bg);
col_off += 1;
}
}
}
return;
}
let lines: Vec<&str> = tooltip.text.lines().collect();
for (i, text_line) in lines.iter().enumerate().take(content_rows as usize) {
let row = y + content_row0 + i as u16;
paint_row_background(buf, row);
for (j, ch) in text_line.chars().enumerate() {
let col = x + text_col_offset + j as u16;
if col + 1 >= x + w {
break;
}
set_cell(buf, col, row, ch, fg, bg);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Rect as QRect;
use crate::primitives::tooltip::{ResolvedPlacement, Tooltip, TooltipLayout};
use crate::types::{StyledSpan, StyledText, WidgetId};
use ratatui::layout::Rect;
fn make_layout(x: f32, y: f32, w: f32, h: f32) -> TooltipLayout {
TooltipLayout {
bounds: QRect::new(x, y, w, h),
resolved_placement: ResolvedPlacement::Bottom,
}
}
fn cell_char(buf: &Buffer, x: u16, y: u16) -> char {
buf[(x, y)].symbol().chars().next().unwrap_or(' ')
}
#[test]
fn painted_bounds_rounds_like_draw_tooltip_does() {
let layout = make_layout(0.4, 1.6, 9.6, 2.5);
let painted = painted_bounds(&layout);
assert_eq!(painted, QRect::new(0.0, 2.0, 10.0, 3.0));
}
#[test]
fn paints_side_borders_and_plain_text() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = Tooltip {
id: WidgetId::new("hover"),
text: "hello".into(),
styled_lines: None,
placement: crate::primitives::tooltip::TooltipPlacement::Bottom,
fg: None,
bg: None,
};
let layout = make_layout(0.0, 0.0, 10.0, 1.0);
draw_tooltip(&mut buf, &tt, &layout, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), '│');
assert_eq!(cell_char(&buf, 9, 0), '│');
let row: String = (2..7).map(|x| cell_char(&buf, x, 0)).collect();
assert_eq!(row, "hello");
}
#[test]
fn paints_a_full_box_when_height_allows_it() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = Tooltip {
id: WidgetId::new("hover"),
text: "hello".into(),
styled_lines: None,
placement: crate::primitives::tooltip::TooltipPlacement::Bottom,
fg: None,
bg: None,
};
let layout = make_layout(0.0, 0.0, 10.0, 3.0);
draw_tooltip(&mut buf, &tt, &layout, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), '┌');
assert_eq!(cell_char(&buf, 9, 0), '┐');
assert_eq!(cell_char(&buf, 4, 0), '─');
assert_eq!(cell_char(&buf, 0, 2), '└');
assert_eq!(cell_char(&buf, 9, 2), '┘');
assert_eq!(cell_char(&buf, 4, 2), '─');
assert_eq!(cell_char(&buf, 0, 1), '│');
assert_eq!(cell_char(&buf, 9, 1), '│');
let row: String = (2..7).map(|x| cell_char(&buf, x, 1)).collect();
assert_eq!(row, "hello");
}
#[test]
fn styled_lines_paint_each_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = Tooltip {
id: WidgetId::new("sig"),
text: String::new(),
styled_lines: Some(vec![
StyledText {
spans: vec![StyledSpan::plain("line1")],
},
StyledText {
spans: vec![StyledSpan::plain("line2")],
},
]),
placement: crate::primitives::tooltip::TooltipPlacement::Bottom,
fg: None,
bg: None,
};
let layout = make_layout(0.0, 0.0, 12.0, 2.0);
draw_tooltip(&mut buf, &tt, &layout, &Theme::default());
let r0: String = (2..7).map(|x| cell_char(&buf, x, 0)).collect();
let r1: String = (2..7).map(|x| cell_char(&buf, x, 1)).collect();
assert_eq!(r0, "line1");
assert_eq!(r1, "line2");
}
#[test]
fn per_tooltip_bg_overrides_theme() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = Tooltip {
id: WidgetId::new("hover"),
text: "x".into(),
styled_lines: None,
placement: crate::primitives::tooltip::TooltipPlacement::Bottom,
fg: None,
bg: Some(Color::rgb(100, 0, 0)),
};
let layout = make_layout(0.0, 0.0, 10.0, 1.0);
draw_tooltip(&mut buf, &tt, &layout, &Theme::default());
let bg = buf[(2u16, 0u16)].bg;
assert_eq!(bg, ratatui::style::Color::Rgb(100, 0, 0));
}
#[test]
fn zero_size_is_a_no_op() {
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 5));
let tt = Tooltip {
id: WidgetId::new("hover"),
text: "x".into(),
styled_lines: None,
placement: crate::primitives::tooltip::TooltipPlacement::Bottom,
fg: None,
bg: None,
};
let layout = make_layout(0.0, 0.0, 0.0, 1.0);
draw_tooltip(&mut buf, &tt, &layout, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), ' ');
}
fn tooltip_with() -> Tooltip {
Tooltip::new(WidgetId::new("hover"), "hi")
}
fn layout_with(
w: f32,
h: f32,
border: TooltipBorder,
title: Option<&str>,
) -> (TooltipLayout, TooltipChrome) {
let mut chrome = TooltipChrome::new(border);
chrome.title = title.map(str::to_string);
(make_layout(0.0, 0.0, w, h), chrome)
}
#[test]
fn sides_border_never_closes_even_when_height_allows_it() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 8));
let mut tt = tooltip_with();
tt.text = "hi\nhi\nhi\nhi\nhi".into();
let (layout, chrome) = layout_with(10.0, 5.0, TooltipBorder::Sides, None);
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
for row in 0..5u16 {
assert_eq!(
cell_char(&buf, 0, row),
'│',
"row {row}: Sides must paint the left bar on every row"
);
assert_eq!(
cell_char(&buf, 9, row),
'│',
"row {row}: Sides must paint the right bar on every row"
);
}
for row in 0..5u16 {
for col in 0..10u16 {
let ch = cell_char(&buf, col, row);
assert!(
!['┌', '┐', '└', '┘', '─'].contains(&ch),
"Sides must never paint box-drawing corner/rule glyphs \
(found {ch:?} at ({col}, {row}))"
);
}
}
}
#[test]
fn none_border_paints_no_chrome() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 8));
let tt = tooltip_with();
let (layout, chrome) = layout_with(10.0, 3.0, TooltipBorder::None, None);
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
for row in 0..3u16 {
for col in 0..10u16 {
let ch = cell_char(&buf, col, row);
assert!(
!['┌', '┐', '└', '┘', '─', '│'].contains(&ch),
"None must paint no border glyph at all (found {ch:?} at ({col}, {row}))"
);
}
}
let row: String = (1..3).map(|x| cell_char(&buf, x, 0)).collect();
assert_eq!(row, "hi");
}
#[test]
fn full_border_centers_title_in_top_row() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = tooltip_with();
let (layout, chrome) = layout_with(12.0, 3.0, TooltipBorder::default(), Some("Hi"));
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
let top_row: String = (0..12).map(|x| cell_char(&buf, x, 0)).collect();
assert_eq!(top_row, "┌─── Hi ───┐");
let bottom_row: String = (0..12).map(|x| cell_char(&buf, x, 2)).collect();
assert_eq!(bottom_row, "└──────────┘");
}
#[test]
fn oversized_title_is_dropped_not_truncated() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = tooltip_with();
let (layout, chrome) = layout_with(
12.0,
3.0,
TooltipBorder::default(),
Some("This title is far too long for the box"),
);
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
let top_row: String = (0..12).map(|x| cell_char(&buf, x, 0)).collect();
assert_eq!(
top_row, "┌──────────┐",
"an oversized title must fall back to a plain rule, not a clipped label"
);
}
#[test]
fn title_is_ignored_when_border_is_not_full() {
for border in [TooltipBorder::Sides, TooltipBorder::None] {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = tooltip_with();
let (layout, chrome) = layout_with(12.0, 3.0, border, Some("Hi"));
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
let screen: String = (0..3)
.flat_map(|row| (0..12).map(move |col| (col, row)))
.map(|(col, row)| cell_char(&buf, col, row))
.collect();
assert!(
!screen.contains("Hi"),
"{border:?}: title must not appear anywhere when there's no top rule to \
embed it in (screen contents: {screen:?})"
);
}
}
#[test]
fn full_border_drops_title_when_too_short_to_close() {
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 5));
let tt = tooltip_with();
let (layout, chrome) = layout_with(12.0, 1.0, TooltipBorder::default(), Some("Hi"));
draw_tooltip_with_chrome(&mut buf, &tt, &layout, &chrome, &Theme::default());
assert_eq!(cell_char(&buf, 0, 0), '│');
let row: String = (0..12).map(|x| cell_char(&buf, x, 0)).collect();
assert!(
!row.contains("Hi"),
"a too-short box has no top row to embed the title in: {row:?}"
);
}
}