use super::view::{View, write_line_to_terminal};
use crate::core::draw::DrawBuffer;
use crate::core::event::{Event, EventType};
use crate::core::geometry::{Point, Rect};
use crate::core::state::StateFlags;
use crate::terminal::Terminal;
use std::time::{Duration, Instant};
const DEFAULT_DELAY: Duration = Duration::from_millis(600);
const HINT_PADDING: usize = 1;
const LABEL_LIGHT: u8 = 2;
struct Hint {
target: Rect,
text: String,
}
pub struct Tooltip {
bounds: Rect,
hints: Vec<Hint>,
hover: Option<(usize, Instant)>,
shown: Option<usize>,
delay: Duration,
view_state: StateFlags,
palette_chain: Option<crate::core::palette_chain::PaletteChainNode>,
}
impl Tooltip {
pub fn new(bounds: Rect) -> Self {
Self {
bounds,
hints: Vec::new(),
hover: None,
shown: None,
delay: DEFAULT_DELAY,
view_state: 0,
palette_chain: None,
}
}
pub fn add_hint(&mut self, target: Rect, text: impl Into<String>) {
self.hints.push(Hint {
target,
text: text.into(),
});
}
pub fn hint_count(&self) -> usize {
self.hints.len()
}
pub fn clear_hints(&mut self) {
self.hints.clear();
self.hover = None;
self.shown = None;
}
pub fn set_delay(&mut self, delay: Duration) {
self.delay = delay;
}
pub fn is_showing(&self) -> bool {
self.shown.is_some()
}
pub fn shown_text(&self) -> Option<&str> {
self.shown.and_then(|i| self.hints.get(i)).map(|h| &*h.text)
}
pub fn hide(&mut self) {
self.hover = None;
self.shown = None;
}
fn hint_at(&self, pos: Point) -> Option<usize> {
self.hints.iter().rposition(|h| h.target.contains(pos))
}
fn track(&mut self, pos: Point) {
match self.hint_at(pos) {
Some(index) => {
let same = self.hover.is_some_and(|(i, _)| i == index);
if !same {
self.hover = Some((index, Instant::now()));
self.shown = None;
}
}
None => self.hide(),
}
}
fn tick(&mut self) {
if self.shown.is_some() {
return;
}
if let Some((index, since)) = self.hover {
if since.elapsed() >= self.delay {
self.shown = Some(index);
}
}
}
fn popup_area(&self, index: usize) -> Option<Rect> {
let hint = self.hints.get(index)?;
let width = hint.text.chars().count() + HINT_PADDING * 2;
let width = (width as i16).min(self.bounds.width()).max(1);
let mut x = hint.target.a.x;
if x + width > self.bounds.b.x {
x = (self.bounds.b.x - width).max(self.bounds.a.x);
}
let mut y = hint.target.b.y;
if y >= self.bounds.b.y {
y = hint.target.a.y - 1;
}
if y < self.bounds.a.y || y >= self.bounds.b.y {
return None;
}
Some(Rect::new(x, y, x + width, y + 1))
}
}
impl View for Tooltip {
fn bounds(&self) -> Rect {
self.bounds
}
fn set_bounds(&mut self, bounds: Rect) {
self.bounds = bounds;
}
fn can_focus(&self) -> bool {
false
}
fn state(&self) -> StateFlags {
self.view_state
}
fn set_state(&mut self, state: StateFlags) {
self.view_state = state;
}
fn draw(&mut self, terminal: &mut Terminal) {
let Some(index) = self.shown else {
return;
};
let Some(area) = self.popup_area(index) else {
return;
};
let width = area.width_clamped().max(0) as usize;
if width == 0 {
return;
}
let attr = self.map_color(LABEL_LIGHT);
let mut buf = DrawBuffer::new(width);
buf.move_char(0, ' ', attr, width);
if HINT_PADDING < width {
let room = width - HINT_PADDING;
let shown: String = self.hints[index].text.chars().take(room).collect();
buf.move_str(HINT_PADDING, &shown, attr);
}
write_line_to_terminal(terminal, area.a.x, area.a.y, &buf);
}
fn handle_event(&mut self, event: &mut Event) {
match event.what {
EventType::MouseMove => {
self.track(event.mouse.pos);
}
EventType::MouseDown | EventType::Keyboard => self.hide(),
EventType::Broadcast => {
if event.command == crate::core::command::CM_IDLE_TICK {
self.tick();
}
}
_ => {}
}
}
fn set_palette_chain(&mut self, node: Option<crate::core::palette_chain::PaletteChainNode>) {
self.palette_chain = node;
}
fn get_palette_chain(&self) -> Option<&crate::core::palette_chain::PaletteChainNode> {
self.palette_chain.as_ref()
}
fn get_palette(&self) -> Option<crate::core::palette::Palette> {
use crate::core::palette::{Palette, palettes};
Some(Palette::from_slice(palettes::CP_LABEL))
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
pub struct TooltipBuilder {
bounds: Option<Rect>,
hints: Vec<(Rect, String)>,
delay: Duration,
}
impl TooltipBuilder {
pub fn new() -> Self {
Self {
bounds: None,
hints: Vec::new(),
delay: DEFAULT_DELAY,
}
}
#[must_use]
pub fn bounds(mut self, bounds: Rect) -> Self {
self.bounds = Some(bounds);
self
}
#[must_use]
pub fn hint(mut self, target: Rect, text: impl Into<String>) -> Self {
self.hints.push((target, text.into()));
self
}
#[must_use]
pub fn delay(mut self, delay: Duration) -> Self {
self.delay = delay;
self
}
pub fn build(self) -> Tooltip {
let bounds = self.bounds.expect("Tooltip bounds must be set");
let mut tip = Tooltip::new(bounds);
tip.set_delay(self.delay);
for (target, text) in self.hints {
tip.add_hint(target, text);
}
tip
}
pub fn build_boxed(self) -> Box<Tooltip> {
Box::new(self.build())
}
}
impl Default for TooltipBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tips() -> Tooltip {
let mut t = Tooltip::new(Rect::new(0, 0, 40, 12));
t.add_hint(Rect::new(2, 2, 12, 3), "First control");
t.add_hint(Rect::new(2, 5, 12, 6), "Second control");
t.set_delay(Duration::ZERO);
t
}
fn move_to(t: &mut Tooltip, x: i16, y: i16) {
let mut e = Event::nothing();
e.what = EventType::MouseMove;
e.mouse.pos = Point::new(x, y);
t.handle_event(&mut e);
}
fn tick(t: &mut Tooltip) {
let mut e = Event::broadcast(crate::core::command::CM_IDLE_TICK);
t.handle_event(&mut e);
}
#[test]
fn nothing_shows_until_the_pointer_arrives() {
let mut t = tips();
assert!(!t.is_showing());
tick(&mut t);
assert!(!t.is_showing());
}
#[test]
fn resting_on_a_control_raises_its_hint() {
let mut t = tips();
move_to(&mut t, 5, 2);
assert!(!t.is_showing(), "not until a tick has passed");
tick(&mut t);
assert_eq!(t.shown_text(), Some("First control"));
}
#[test]
fn the_delay_must_actually_elapse() {
let mut t = tips();
t.set_delay(Duration::from_secs(30));
move_to(&mut t, 5, 2);
tick(&mut t);
assert!(!t.is_showing());
}
#[test]
fn moving_off_a_control_takes_the_hint_down() {
let mut t = tips();
move_to(&mut t, 5, 2);
tick(&mut t);
assert!(t.is_showing());
move_to(&mut t, 30, 9);
assert!(!t.is_showing());
}
#[test]
fn moving_between_controls_restarts_the_delay() {
let mut t = tips();
move_to(&mut t, 5, 2);
tick(&mut t);
move_to(&mut t, 5, 5);
assert!(!t.is_showing(), "the second hint has not waited yet");
tick(&mut t);
assert_eq!(t.shown_text(), Some("Second control"));
}
#[test]
fn staying_on_one_control_does_not_restart_the_delay() {
let mut t = tips();
t.set_delay(Duration::from_millis(1));
move_to(&mut t, 5, 2);
move_to(&mut t, 6, 2);
std::thread::sleep(Duration::from_millis(2));
tick(&mut t);
assert!(
t.is_showing(),
"a nudge within the same control still counts"
);
}
#[test]
fn typing_or_clicking_takes_the_hint_down() {
for mut event in [
{
let mut e = Event::nothing();
e.what = EventType::MouseDown;
e
},
Event::keyboard(crate::core::event::KB_DOWN),
] {
let mut t = tips();
move_to(&mut t, 5, 2);
tick(&mut t);
assert!(t.is_showing());
t.handle_event(&mut event);
assert!(!t.is_showing());
}
}
#[test]
fn the_mouse_move_is_left_for_other_views() {
let mut t = tips();
let mut e = Event::nothing();
e.what = EventType::MouseMove;
e.mouse.pos = Point::new(5, 2);
t.handle_event(&mut e);
assert_eq!(e.what, EventType::MouseMove);
}
#[test]
fn the_idle_tick_is_left_for_other_views() {
let mut t = tips();
let mut e = Event::broadcast(crate::core::command::CM_IDLE_TICK);
t.handle_event(&mut e);
assert_eq!(e.what, EventType::Broadcast);
}
#[test]
fn the_popup_sits_under_its_control() {
let t = tips();
let area = t.popup_area(0).unwrap();
assert_eq!(area.a, Point::new(2, 3), "the row below the control");
assert_eq!(area.width(), "First control".len() as i16 + 2);
}
#[test]
fn a_popup_near_the_right_edge_is_pushed_left() {
let mut t = Tooltip::new(Rect::new(0, 0, 20, 10));
t.add_hint(Rect::new(16, 2, 19, 3), "A long hint");
let area = t.popup_area(0).unwrap();
assert!(area.b.x <= 20, "stayed inside the bounds: {area:?}");
}
#[test]
fn a_popup_with_no_room_below_flips_above() {
let mut t = Tooltip::new(Rect::new(0, 0, 20, 10));
t.add_hint(Rect::new(2, 9, 10, 10), "Bottom control");
let area = t.popup_area(0).unwrap();
assert_eq!(area.a.y, 8, "above the control");
}
#[test]
fn overlapping_hints_prefer_the_one_added_later() {
let mut t = Tooltip::new(Rect::new(0, 0, 40, 12));
t.set_delay(Duration::ZERO);
t.add_hint(Rect::new(0, 0, 20, 6), "The group");
t.add_hint(Rect::new(2, 2, 10, 3), "The control inside it");
move_to(&mut t, 5, 2);
tick(&mut t);
assert_eq!(t.shown_text(), Some("The control inside it"));
}
#[test]
fn clearing_hints_takes_anything_showing_down() {
let mut t = tips();
move_to(&mut t, 5, 2);
tick(&mut t);
t.clear_hints();
assert_eq!(t.hint_count(), 0);
assert!(!t.is_showing());
}
#[test]
fn builder_configures_the_tooltip() {
let t = TooltipBuilder::new()
.bounds(Rect::new(0, 0, 30, 8))
.hint(Rect::new(1, 1, 5, 2), "Hint")
.delay(Duration::from_millis(50))
.build();
assert_eq!(t.hint_count(), 1);
assert_eq!(t.delay, Duration::from_millis(50));
}
}