use std::rc::Rc;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
text::Line,
widgets::{Block, BorderType, Borders, Paragraph, Widget},
};
use crate::Theme;
use crate::runtime::{
BodySlot, Component, Event, EventCtx, EventResult, KeyCode, MouseKind, RenderCtx, ScopeOptions,
wrapped_height,
};
use crate::text_width::{display_width_u16, wrap_to_width};
const PADDING: u16 = 1;
const CHROME: u16 = 2 + PADDING * 2;
const BORDER_ROWS: u16 = 2;
const BUBBLE_ID: &str = "bubble";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TooltipSide {
#[default]
Top,
Bottom,
Left,
Right,
}
impl TooltipSide {
#[must_use]
pub const fn opposite(self) -> Self {
match self {
Self::Top => Self::Bottom,
Self::Bottom => Self::Top,
Self::Left => Self::Right,
Self::Right => Self::Left,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TooltipStyle {
pub foreground: Color,
pub background: Color,
pub border: Color,
}
impl TooltipStyle {
#[must_use]
pub const fn fallback() -> Self {
Self {
foreground: Color::White,
background: Color::Reset,
border: Color::DarkGray,
}
}
#[must_use]
pub const fn from_theme(theme: &Theme) -> Self {
Self {
foreground: theme.foreground,
background: theme.surface,
border: theme.border,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TooltipWidget<'a> {
text: &'a str,
style: TooltipStyle,
}
impl<'a> TooltipWidget<'a> {
#[must_use]
pub const fn new(text: &'a str) -> Self {
Self {
text,
style: TooltipStyle::fallback(),
}
}
#[must_use]
pub const fn themed(mut self, theme: &Theme) -> Self {
self.style = TooltipStyle::from_theme(theme);
self
}
#[must_use]
pub const fn style(mut self, style: TooltipStyle) -> Self {
self.style = style;
self
}
#[must_use]
pub fn width(&self) -> u16 {
self.text
.lines()
.map(display_width_u16)
.max()
.unwrap_or(0)
.saturating_add(CHROME)
}
#[must_use]
pub fn height(&self, width: u16) -> u16 {
let inner = width.saturating_sub(CHROME);
if inner == 0 {
return BORDER_ROWS;
}
wrapped_height(self.text, inner).saturating_add(BORDER_ROWS)
}
}
impl Widget for TooltipWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
let block = Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(self.style.border).bg(self.style.background))
.style(Style::new().bg(self.style.background));
let inner = block.inner(area);
block.render(area, buf);
let text_area = Rect {
x: inner.x.saturating_add(PADDING),
width: inner.width.saturating_sub(PADDING * 2),
..inner
}
.intersection(*buf.area());
if text_area.is_empty() {
return;
}
let lines = wrap_to_width(self.text, usize::from(text_area.width))
.into_iter()
.map(Line::from)
.collect::<Vec<_>>();
Paragraph::new(lines)
.style(
Style::new()
.fg(self.style.foreground)
.bg(self.style.background),
)
.render(text_area, buf);
}
}
type ReadOpenFn<S> = Rc<dyn Fn(&S) -> bool>;
type OnOpenChangeFn<M> = Rc<dyn Fn(bool) -> M>;
type OpenBinding<S, M> = (ReadOpenFn<S>, Option<OnOpenChangeFn<M>>);
type StyleFn = Rc<dyn Fn(&Theme) -> TooltipStyle>;
pub struct Tooltip<S, M> {
text: String,
side: TooltipSide,
max_width: u16,
open: Option<OpenBinding<S, M>>,
trigger: BodySlot<S, M>,
style: Option<StyleFn>,
resolved_open: bool,
}
impl<S, M> std::fmt::Debug for Tooltip<S, M> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Tooltip")
.field("text", &self.text)
.field("side", &self.side)
.field("max_width", &self.max_width)
.field("open", &self.open.is_some())
.field("trigger", &self.trigger.is_configured())
.finish_non_exhaustive()
}
}
impl<S, M> Tooltip<S, M> {
pub const DEFAULT_MAX_WIDTH: u16 = 40;
#[must_use]
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
side: TooltipSide::default(),
max_width: Self::DEFAULT_MAX_WIDTH,
open: None,
trigger: BodySlot::None,
style: None,
resolved_open: false,
}
}
#[must_use]
pub fn open_when(mut self, read: impl Fn(&S) -> bool + 'static) -> Self {
self.open = Some((Rc::new(read), None));
self
}
#[must_use]
pub fn open(
mut self,
read: impl Fn(&S) -> bool + 'static,
on_open_change: impl Fn(bool) -> M + 'static,
) -> Self {
self.open = Some((Rc::new(read), Some(Rc::new(on_open_change))));
self
}
#[must_use]
pub fn trigger(mut self, f: impl FnOnce(&mut RenderCtx<'_, '_, S, M>) + 'static) -> Self
where
S: 'static,
M: 'static,
{
self.trigger.set(f);
self
}
#[must_use]
pub const fn side(mut self, side: TooltipSide) -> Self {
self.side = side;
self
}
#[must_use]
pub const fn max_width(mut self, max_width: u16) -> Self {
self.max_width = max_width;
self
}
#[must_use]
pub fn style(mut self, style: impl Fn(&Theme) -> TooltipStyle + 'static) -> Self {
self.style = Some(Rc::new(style));
self
}
fn is_open(&self, state: &S) -> bool {
self.open.as_ref().is_some_and(|(read, _)| read(state))
}
fn request(&self, open: bool) -> EventResult<M> {
match self.open.as_ref() {
Some((_, Some(on_open_change))) => EventResult::Emit(on_open_change(open)),
_ => EventResult::Ignored,
}
}
}
impl<S: 'static, M: 'static> Component<S, M> for Tooltip<S, M> {
fn prepare(&mut self, state: &S) {
self.resolved_open = self.is_open(state);
}
fn render(&mut self, ctx: &mut RenderCtx<'_, '_, S, M>) {
if let Some(trigger) = self.trigger.consume() {
trigger(ctx);
}
if !self.resolved_open {
return;
}
let style = self.style.as_ref().map_or_else(
|| TooltipStyle::from_theme(ctx.theme),
|style| style(ctx.theme),
);
let widget = TooltipWidget::new(&self.text).style(style);
let bounds = ctx.frame_area();
let width = widget.width().min(self.max_width).min(bounds.width);
let height = widget.height(width).min(bounds.height);
let Some(area) = bubble_area(ctx.area(), bounds, self.side, width, height) else {
return;
};
ctx.hint(BUBBLE_ID, ScopeOptions::default(), area, move |ctx| {
ctx.render_widget(widget, area);
});
}
fn handle_event(
&mut self,
event: &Event,
state: &S,
_ctx: &mut EventCtx<'_>,
) -> EventResult<M> {
let open = self.is_open(state);
match event {
Event::Key(key) if open && key.code == KeyCode::Esc && !key.modifiers.any() => {
self.request(false)
}
Event::Mouse(mouse) if !open && mouse.kind == MouseKind::Moved => self.request(true),
_ => EventResult::Ignored,
}
}
}
fn bubble_area(
trigger: Rect,
bounds: Rect,
side: TooltipSide,
width: u16,
height: u16,
) -> Option<Rect> {
if width == 0 || height == 0 || bounds.is_empty() {
return None;
}
let side = if fits(trigger, bounds, side, width, height) {
side
} else {
let flipped = side.opposite();
if fits(trigger, bounds, flipped, width, height) {
flipped
} else {
side
}
};
let (x, y) = match side {
TooltipSide::Top => (
center(trigger.x, trigger.width, width),
trigger.y.saturating_sub(height),
),
TooltipSide::Bottom => (center(trigger.x, trigger.width, width), trigger.bottom()),
TooltipSide::Left => (
trigger.x.saturating_sub(width),
center(trigger.y, trigger.height, height),
),
TooltipSide::Right => (trigger.right(), center(trigger.y, trigger.height, height)),
};
Some(Rect::new(
clamp(x, width, bounds.x, bounds.right()),
clamp(y, height, bounds.y, bounds.bottom()),
width,
height,
))
}
const fn fits(trigger: Rect, bounds: Rect, side: TooltipSide, width: u16, height: u16) -> bool {
match side {
TooltipSide::Top => trigger.y >= bounds.y.saturating_add(height),
TooltipSide::Bottom => trigger.bottom().saturating_add(height) <= bounds.bottom(),
TooltipSide::Left => trigger.x >= bounds.x.saturating_add(width),
TooltipSide::Right => trigger.right().saturating_add(width) <= bounds.right(),
}
}
const fn center(start: u16, extent: u16, size: u16) -> u16 {
start
.saturating_add(extent / 2)
.saturating_sub(size.div_ceil(2))
}
const fn clamp(start: u16, size: u16, low: u16, high: u16) -> u16 {
let last = high.saturating_sub(size);
if start > last {
last
} else if start < low {
low
} else {
start
}
}
#[cfg(test)]
mod tests {
use ratatui::{Terminal, backend::TestBackend};
use super::*;
use crate::Button;
use crate::runtime::{
ChildId, FocusState, HoverState, KeyEvent, Modifiers, MouseButton, MouseEvent, Ratcn,
};
const TIP: &str = "Save the file";
#[derive(Debug, PartialEq)]
enum Msg {
Open(bool),
Pressed,
Focus(FocusState),
Hover(HoverState),
}
#[derive(Default)]
struct State {
open: bool,
focus: FocusState,
hover: HoverState,
}
fn tooltip(side: TooltipSide) -> Tooltip<State, Msg> {
Tooltip::new(TIP)
.side(side)
.open(|state: &State| state.open, Msg::Open)
.trigger(|ctx| {
let area = ctx.area();
ctx.render_component("save", Button::new("Save").on_press(|| Msg::Pressed), area);
})
}
struct Driver {
terminal: Terminal<TestBackend>,
ratcn: Ratcn<State, Msg>,
}
impl Driver {
fn new(width: u16, height: u16) -> Self {
Self {
terminal: Terminal::new(TestBackend::new(width, height)).expect("terminal"),
ratcn: Ratcn::new().focus(|state: &State| &state.focus, Msg::Focus),
}
}
fn hovering(mut self) -> Self {
self.ratcn = self.ratcn.hover(|state: &State| &state.hover, Msg::Hover);
self
}
fn render(&mut self, state: &State, area: Rect, side: TooltipSide) {
let theme = Theme::default_dark();
self.terminal
.draw(|frame| {
self.ratcn.render(frame, state, &theme, |ctx| {
ctx.render_component(
"before",
Button::new("Open").on_press(|| Msg::Pressed),
Rect::new(0, 0, 6, 1),
);
ctx.render_component("tip", tooltip(side), area);
});
})
.expect("draw");
}
fn event(&mut self, event: Event, state: &State) -> EventResult<Msg> {
self.ratcn.handle_event(event, state)
}
fn row(&self, row: u16) -> String {
let buffer = self.terminal.backend().buffer();
(0..buffer.area.width)
.map(|column| buffer.cell((column, row)).expect("cell").symbol())
.collect()
}
}
fn mouse(kind: MouseKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: Modifiers::NONE,
})
}
#[test]
fn a_read_only_binding_shows_the_bubble_and_emits_nothing() {
let mut driver = Driver::new(30, 10);
let state = State {
open: true,
..State::default()
};
let theme = Theme::default_dark();
driver
.terminal
.draw(|frame| {
driver.ratcn.render(frame, &state, &theme, |ctx| {
ctx.render_component(
"before",
Button::new("Open").on_press(|| Msg::Pressed),
Rect::new(0, 0, 6, 1),
);
ctx.render_component(
"tip",
Tooltip::new(TIP)
.open_when(|state: &State| state.open)
.trigger(|ctx| {
let area = ctx.area();
ctx.render_component(
"save",
Button::new("Save").on_press(|| Msg::Pressed),
area,
);
}),
Rect::new(2, 5, 8, 3),
);
});
})
.expect("draw");
assert!(
(0..10).any(|row| driver.row(row).contains(TIP)),
"a read-only binding still shows the bubble"
);
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Esc)), &state),
EventResult::Ignored,
"with nothing to write back, Esc bubbles to the app instead"
);
assert_eq!(
driver.event(mouse(MouseKind::Moved, 4, 6), &state),
EventResult::Ignored,
"and pointer motion asks for nothing"
);
}
#[test]
fn pointer_over_the_trigger_asks_to_open_once() {
let mut driver = Driver::new(30, 10);
let mut state = State::default();
driver.render(&state, Rect::new(2, 5, 8, 3), TooltipSide::Top);
assert_eq!(
driver.event(mouse(MouseKind::Moved, 4, 6), &state),
EventResult::Emit(Msg::Open(true))
);
state.open = true;
assert_eq!(
driver.event(mouse(MouseKind::Moved, 4, 6), &state),
EventResult::Ignored
);
}
#[test]
fn pointer_leaving_the_trigger_reports_through_hover_not_the_tooltip() {
let mut driver = Driver::new(30, 10).hovering();
let state = State {
open: true,
hover: HoverState::intent(["tip", "save"].map(ChildId::from)),
..State::default()
};
driver.render(&state, Rect::new(2, 5, 8, 3), TooltipSide::Top);
assert_eq!(
driver.event(mouse(MouseKind::Moved, 25, 9), &state),
EventResult::Emit(Msg::Hover(HoverState::default()))
);
}
#[test]
fn escape_while_open_asks_to_close() {
let mut driver = Driver::new(30, 10);
let state = State {
open: true,
focus: FocusState::intent(["tip", "save"]),
..State::default()
};
driver.render(&state, Rect::new(2, 5, 8, 3), TooltipSide::Top);
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Esc)), &state),
EventResult::Emit(Msg::Open(false))
);
let closed = State {
focus: FocusState::intent(["tip", "save"]),
..State::default()
};
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Esc)), &closed),
EventResult::Ignored
);
}
#[test]
fn the_bubble_flips_to_the_opposite_side_when_the_preferred_one_has_no_room() {
let bounds = Rect::new(0, 0, 30, 10);
let top_trigger = Rect::new(4, 0, 8, 1);
let placed = bubble_area(top_trigger, bounds, TooltipSide::Top, 10, 3).expect("bubble");
assert_eq!(placed.y, top_trigger.bottom(), "flipped below");
let bottom_trigger = Rect::new(4, 9, 8, 1);
let placed =
bubble_area(bottom_trigger, bounds, TooltipSide::Bottom, 10, 3).expect("bubble");
assert_eq!(placed.y, bottom_trigger.y - 3, "flipped above");
let left_trigger = Rect::new(0, 4, 8, 1);
let placed = bubble_area(left_trigger, bounds, TooltipSide::Left, 10, 3).expect("bubble");
assert_eq!(placed.x, left_trigger.right(), "flipped right");
let tall = Rect::new(0, 0, 30, 3);
let placed =
bubble_area(Rect::new(4, 1, 8, 1), tall, TooltipSide::Top, 10, 3).expect("bubble");
assert_eq!(placed.y, 0);
assert!(tall.contains(ratatui::layout::Position::new(placed.x, placed.y)));
}
#[test]
fn the_bubble_is_centred_on_the_trigger_and_clamped_inside_the_frame() {
let bounds = Rect::new(0, 0, 30, 10);
let placed =
bubble_area(Rect::new(10, 5, 8, 1), bounds, TooltipSide::Top, 10, 3).expect("bubble");
assert_eq!(placed.x, 9, "centred on the trigger");
assert_eq!(placed.y, 2);
let placed =
bubble_area(Rect::new(26, 5, 4, 1), bounds, TooltipSide::Top, 10, 3).expect("bubble");
assert_eq!(placed.right(), bounds.right(), "pulled inside the frame");
}
#[test]
fn the_tooltip_is_never_a_focus_stop() {
let mut driver = Driver::new(30, 10);
let state = State::default();
driver.render(&state, Rect::new(2, 5, 8, 3), TooltipSide::Top);
assert_eq!(
driver.event(Event::Key(KeyEvent::new(KeyCode::Tab)), &state),
EventResult::Emit(Msg::Focus(FocusState::intent(["tip", "save"]))),
"focus lands on the trigger, never on its tooltip"
);
}
#[test]
fn a_press_over_the_bubble_reaches_the_trigger_beneath_it() {
let frame = Rect::new(0, 0, 30, 4);
let trigger = Rect::new(2, 1, 20, 1);
let mut driver = Driver::new(frame.width, frame.height);
let state = State {
open: true,
..State::default()
};
driver.render(&state, trigger, TooltipSide::Top);
let width = TooltipWidget::new(TIP).width();
let bubble = bubble_area(trigger, frame, TooltipSide::Top, width, 3).expect("bubble");
assert!(bubble.intersects(trigger), "{bubble:?}");
driver.event(
mouse(MouseKind::Down(MouseButton::Left), trigger.x, trigger.y),
&state,
);
assert_eq!(
driver.event(
mouse(MouseKind::Click(MouseButton::Left), trigger.x, trigger.y),
&state
),
EventResult::Emit(Msg::Pressed),
"the hint layer takes no pointer input, so the trigger still presses"
);
}
#[test]
fn the_bubble_paints_above_everything_else() {
let mut driver = Driver::new(30, 10);
let state = State {
open: true,
..State::default()
};
driver.render(&state, Rect::new(2, 5, 8, 1), TooltipSide::Top);
assert!(driver.row(3).contains(TIP), "{}", driver.row(3));
assert!(driver.row(2).contains('╭'), "{}", driver.row(2));
}
#[test]
fn the_widget_measures_and_paints_the_same_bordered_box() {
let widget = TooltipWidget::new("Save").style(TooltipStyle::fallback());
assert_eq!(widget.width(), 4 + CHROME);
assert_eq!(widget.height(widget.width()), 1 + BORDER_ROWS);
let area = Rect::new(0, 0, widget.width(), widget.height(widget.width()));
let mut buffer = Buffer::empty(area);
widget.render(area, &mut buffer);
let row: String = (0..area.width)
.map(|column| buffer.cell((column, 1)).expect("cell").symbol())
.collect();
assert_eq!(row, "│ Save │");
assert_eq!(buffer.cell((0, 0)).expect("corner").symbol(), "╭");
assert_eq!(
buffer.cell((2, 1)).expect("text").style().fg,
Some(TooltipStyle::fallback().foreground)
);
}
#[test]
fn narrow_widths_wrap_and_are_measured_as_extra_rows() {
let widget = TooltipWidget::new("Save the file");
assert!(widget.height(9) > widget.height(widget.width()));
assert_eq!(widget.height(CHROME), BORDER_ROWS);
}
#[test]
fn theme_colors_separate_the_bubble_from_the_surface_behind_it() {
for theme in Theme::presets() {
let style = TooltipStyle::from_theme(theme);
assert_ne!(style.background, style.foreground, "{}", theme.name);
assert_ne!(style.border, style.background, "{}", theme.name);
}
}
}