Skip to main content

hjkl_hover/
lib.rs

1//! Renderer-agnostic hover popup state and geometry.
2//!
3//! Handles `HoverState`, anchor/viewport types, position math (with
4//! above/below overflow flip), and keyboard-driven scroll. No rendering
5//! types are referenced — the TUI adapter lives in `hjkl-hover-tui`.
6//!
7//! # Quick start
8//!
9//! ```rust
10//! use hjkl_hover::{HoverAnchor, HoverState, HoverViewport, position};
11//! use std::time::Instant;
12//!
13//! let state = HoverState::new("hello world".to_string(), HoverAnchor::new(5, 3));
14//! let vp = HoverViewport::new(80, 24);
15//! let rect = position(&state, vp);
16//! assert!(rect.x + rect.width <= 80);
17//! assert!(rect.y + rect.height <= 24);
18//! ```
19
20use std::time::{Duration, Instant};
21
22// ── Public types ──────────────────────────────────────────────────────────────
23
24/// Duration before a stationary hover popup is automatically dismissed.
25pub const HOVER_AUTO_FADE: Duration = Duration::from_secs(8);
26
27/// Maximum popup width (content columns + 2 border).
28pub const DEFAULT_MAX_WIDTH: u16 = 62;
29
30/// Maximum popup height (content rows + 2 border).
31pub const DEFAULT_MAX_HEIGHT: u16 = 14;
32
33/// Cursor cell that the popup is anchored to.
34///
35/// `#[non_exhaustive]` — new fields may be added in minor releases.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct HoverAnchor {
39    /// 0-based terminal column (x).
40    pub col: u16,
41    /// 0-based terminal row (y).
42    pub row: u16,
43}
44
45impl HoverAnchor {
46    /// Convenience constructor.
47    pub fn new(col: u16, row: u16) -> Self {
48        Self { col, row }
49    }
50}
51
52/// Available terminal area for popup placement.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct HoverViewport {
56    pub width: u16,
57    pub height: u16,
58}
59
60impl HoverViewport {
61    /// Construct a new `HoverViewport`.
62    pub fn new(width: u16, height: u16) -> Self {
63        Self { width, height }
64    }
65}
66
67/// On-screen bounding rect returned by [`position`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69#[non_exhaustive]
70pub struct HoverRect {
71    pub x: u16,
72    pub y: u16,
73    pub width: u16,
74    pub height: u16,
75}
76
77impl HoverRect {
78    /// Construct a new `HoverRect`.
79    pub fn new(x: u16, y: u16, width: u16, height: u16) -> Self {
80        Self {
81            x,
82            y,
83            width,
84            height,
85        }
86    }
87}
88
89/// All state needed to display and manage a hover popup.
90///
91/// `#[non_exhaustive]` — new fields may be added in minor releases.
92#[non_exhaustive]
93pub struct HoverState {
94    /// Raw markdown (or plain-text) hover content as returned by the LSP.
95    pub content: String,
96    /// Terminal cell the popup is anchored to.
97    pub anchor: HoverAnchor,
98    /// Whether the popup has been explicitly dismissed.
99    pub dismissed: bool,
100    /// Scroll offset: number of content lines scrolled past the top.
101    pub scroll: usize,
102    /// When the popup was first shown — used for auto-fade.
103    pub displayed_at: Instant,
104    /// Optional hard expiry override (overrides the default 8-second auto-fade).
105    pub expiry: Option<Instant>,
106    /// Maximum popup width in terminal columns (including border).
107    pub max_width: u16,
108    /// Maximum popup height in terminal rows (including border).
109    pub max_height: u16,
110}
111
112impl HoverState {
113    /// Create a new popup anchored at `anchor` with default size limits.
114    pub fn new(content: String, anchor: HoverAnchor) -> Self {
115        Self {
116            content,
117            anchor,
118            dismissed: false,
119            scroll: 0,
120            displayed_at: Instant::now(),
121            expiry: None,
122            max_width: DEFAULT_MAX_WIDTH,
123            max_height: DEFAULT_MAX_HEIGHT,
124        }
125    }
126
127    /// Returns `true` when the popup has expired (`now` past the deadline).
128    pub fn is_expired(&self, now: Instant) -> bool {
129        if self.dismissed {
130            return true;
131        }
132        if let Some(exp) = self.expiry {
133            return now >= exp;
134        }
135        now.duration_since(self.displayed_at) >= HOVER_AUTO_FADE
136    }
137
138    /// Scroll the popup by `n` lines (positive = down, negative = up).
139    ///
140    /// Scroll is clamped at 0; the upper bound is enforced by the renderer
141    /// against the rendered line count.
142    pub fn scroll_lines(&mut self, n: isize) {
143        if n < 0 {
144            self.scroll = self.scroll.saturating_sub((-n) as usize);
145        } else {
146            self.scroll = self.scroll.saturating_add(n as usize);
147        }
148    }
149
150    /// Number of non-empty content lines (for scroll clamping in the renderer).
151    pub fn line_count(&self) -> usize {
152        self.content.lines().count().max(1)
153    }
154}
155
156// ── Position math ─────────────────────────────────────────────────────────────
157
158/// Compute the on-screen bounding rect for a popup.
159///
160/// Preferred position: one row below the anchor column.
161/// If there is not enough vertical room below, flips above the anchor row.
162/// Horizontally, shifts left if the popup would overflow the right edge.
163/// The result is always clamped to stay fully inside `viewport`.
164pub fn position(state: &HoverState, viewport: HoverViewport) -> HoverRect {
165    let ax = state.anchor.col;
166    let ay = state.anchor.row;
167
168    // Compute content dimensions from raw text.
169    let content_w: u16 = state
170        .content
171        .lines()
172        .map(|l| l.len() as u16)
173        .max()
174        .unwrap_or(8);
175    let content_h: u16 = state.content.lines().count().max(1) as u16;
176
177    // Total popup size including border (1 cell each side).
178    let popup_w = (content_w + 2)
179        .min(state.max_width)
180        .max(4)
181        .min(viewport.width);
182    let popup_h = (content_h + 2)
183        .min(state.max_height)
184        .max(3)
185        .min(viewport.height);
186
187    // Horizontal: prefer anchor col, shift left if overflowing right edge.
188    let x = if ax + popup_w <= viewport.width {
189        ax
190    } else {
191        viewport.width.saturating_sub(popup_w)
192    };
193
194    // Vertical: prefer one row below; flip above if no room.
195    let below_y = ay.saturating_add(1);
196    let y = if below_y + popup_h <= viewport.height {
197        below_y
198    } else {
199        ay.saturating_sub(popup_h)
200    };
201
202    HoverRect {
203        x,
204        y,
205        width: popup_w,
206        height: popup_h,
207    }
208}
209
210// ── Tests ─────────────────────────────────────────────────────────────────────
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    fn vp(w: u16, h: u16) -> HoverViewport {
217        HoverViewport {
218            width: w,
219            height: h,
220        }
221    }
222
223    fn state(content: &str, col: u16, row: u16) -> HoverState {
224        HoverState::new(content.to_string(), HoverAnchor::new(col, row))
225    }
226
227    #[test]
228    fn popup_stays_inside_right_edge() {
229        let s = state("hello world\nsecond line", 75, 5);
230        let r = position(&s, vp(80, 24));
231        assert!(
232            r.x + r.width <= 80,
233            "overflow right: x={} w={} vp=80",
234            r.x,
235            r.width
236        );
237    }
238
239    #[test]
240    fn popup_flips_above_when_no_vertical_room() {
241        let s = state("line1\nline2\nline3", 0, 22);
242        let r = position(&s, vp(80, 24));
243        assert!(r.y < 22, "should be above anchor row 22, got y={}", r.y);
244    }
245
246    #[test]
247    fn popup_shows_below_when_room() {
248        let s = state("short", 0, 0);
249        let r = position(&s, vp(80, 24));
250        assert!(r.y > 0, "should be below anchor at row 0");
251    }
252
253    #[test]
254    fn scroll_clamps_at_zero() {
255        let mut s = state("x", 0, 0);
256        s.scroll_lines(-10);
257        assert_eq!(s.scroll, 0);
258    }
259
260    #[test]
261    fn scroll_down() {
262        let mut s = state("x", 0, 0);
263        s.scroll_lines(3);
264        assert_eq!(s.scroll, 3);
265    }
266
267    #[test]
268    fn is_expired_after_explicit_expiry() {
269        let mut s = state("x", 0, 0);
270        s.expiry = Some(Instant::now() - Duration::from_millis(1));
271        assert!(s.is_expired(Instant::now()));
272    }
273
274    #[test]
275    fn not_expired_fresh() {
276        let s = state("x", 0, 0);
277        assert!(!s.is_expired(Instant::now()));
278    }
279
280    #[test]
281    fn dismissed_counts_as_expired() {
282        let mut s = state("x", 0, 0);
283        s.dismissed = true;
284        assert!(s.is_expired(Instant::now()));
285    }
286}