use std::time::{Duration, Instant};
pub const HOVER_AUTO_FADE: Duration = Duration::from_secs(8);
pub const DEFAULT_MAX_WIDTH: u16 = 62;
pub const DEFAULT_MAX_HEIGHT: u16 = 14;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HoverAnchor {
pub col: u16,
pub row: u16,
}
impl HoverAnchor {
pub fn new(col: u16, row: u16) -> Self {
Self { col, row }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HoverViewport {
pub width: u16,
pub height: u16,
}
impl HoverViewport {
pub fn new(width: u16, height: u16) -> Self {
Self { width, height }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct HoverRect {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
impl HoverRect {
pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
Self {
x,
y,
width,
height,
}
}
}
#[non_exhaustive]
pub struct HoverState {
pub content: String,
pub anchor: HoverAnchor,
pub dismissed: bool,
pub scroll: usize,
pub displayed_at: Instant,
pub expiry: Option<Instant>,
pub max_width: u16,
pub max_height: u16,
}
impl HoverState {
pub fn new(content: String, anchor: HoverAnchor) -> Self {
Self {
content,
anchor,
dismissed: false,
scroll: 0,
displayed_at: Instant::now(),
expiry: None,
max_width: DEFAULT_MAX_WIDTH,
max_height: DEFAULT_MAX_HEIGHT,
}
}
pub fn is_expired(&self, now: Instant) -> bool {
if self.dismissed {
return true;
}
if let Some(exp) = self.expiry {
return now >= exp;
}
now.duration_since(self.displayed_at) >= HOVER_AUTO_FADE
}
pub fn scroll_lines(&mut self, n: isize) {
if n < 0 {
self.scroll = self.scroll.saturating_sub((-n) as usize);
} else {
self.scroll = self.scroll.saturating_add(n as usize);
}
}
pub fn line_count(&self) -> usize {
self.content.lines().count().max(1)
}
}
pub fn position(state: &HoverState, viewport: HoverViewport) -> HoverRect {
let ax = state.anchor.col;
let ay = state.anchor.row;
let content_w: u16 = state
.content
.lines()
.map(|l| l.len() as u16)
.max()
.unwrap_or(8);
let content_h: u16 = state.content.lines().count().max(1) as u16;
let popup_w = (content_w + 2)
.min(state.max_width)
.max(4)
.min(viewport.width);
let popup_h = (content_h + 2)
.min(state.max_height)
.max(3)
.min(viewport.height);
let x = if ax + popup_w <= viewport.width {
ax
} else {
viewport.width.saturating_sub(popup_w)
};
let below_y = ay.saturating_add(1);
let y = if below_y + popup_h <= viewport.height {
below_y
} else {
ay.saturating_sub(popup_h)
};
HoverRect {
x,
y,
width: popup_w,
height: popup_h,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vp(w: u16, h: u16) -> HoverViewport {
HoverViewport {
width: w,
height: h,
}
}
fn state(content: &str, col: u16, row: u16) -> HoverState {
HoverState::new(content.to_string(), HoverAnchor::new(col, row))
}
#[test]
fn popup_stays_inside_right_edge() {
let s = state("hello world\nsecond line", 75, 5);
let r = position(&s, vp(80, 24));
assert!(
r.x + r.width <= 80,
"overflow right: x={} w={} vp=80",
r.x,
r.width
);
}
#[test]
fn popup_flips_above_when_no_vertical_room() {
let s = state("line1\nline2\nline3", 0, 22);
let r = position(&s, vp(80, 24));
assert!(r.y < 22, "should be above anchor row 22, got y={}", r.y);
}
#[test]
fn popup_shows_below_when_room() {
let s = state("short", 0, 0);
let r = position(&s, vp(80, 24));
assert!(r.y > 0, "should be below anchor at row 0");
}
#[test]
fn scroll_clamps_at_zero() {
let mut s = state("x", 0, 0);
s.scroll_lines(-10);
assert_eq!(s.scroll, 0);
}
#[test]
fn scroll_down() {
let mut s = state("x", 0, 0);
s.scroll_lines(3);
assert_eq!(s.scroll, 3);
}
#[test]
fn is_expired_after_explicit_expiry() {
let mut s = state("x", 0, 0);
s.expiry = Some(Instant::now() - Duration::from_millis(1));
assert!(s.is_expired(Instant::now()));
}
#[test]
fn not_expired_fresh() {
let s = state("x", 0, 0);
assert!(!s.is_expired(Instant::now()));
}
#[test]
fn dismissed_counts_as_expired() {
let mut s = state("x", 0, 0);
s.dismissed = true;
assert!(s.is_expired(Instant::now()));
}
}