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    /// Clamped to `[0, line_count - 1]`. The lower bound stops back-scroll at
141    /// the top; the upper bound stops the model value from running away past
142    /// the content (which would make an equal number of up-presses do nothing
143    /// before the popup visibly moves). The renderer still clamps further
144    /// against the actual viewport height.
145    pub fn scroll_lines(&mut self, n: isize) {
146        let max = self.line_count().saturating_sub(1);
147        if n < 0 {
148            self.scroll = self.scroll.saturating_sub((-n) as usize);
149        } else {
150            self.scroll = self.scroll.saturating_add(n as usize).min(max);
151        }
152    }
153
154    /// Number of non-empty content lines (for scroll clamping in the renderer).
155    pub fn line_count(&self) -> usize {
156        self.content.lines().count().max(1)
157    }
158}
159
160// ── Position math ─────────────────────────────────────────────────────────────
161
162/// Compute the on-screen bounding rect for a popup.
163///
164/// Preferred position: one row below the anchor column.
165/// If there is not enough vertical room below, flips above the anchor row.
166/// Horizontally, shifts left if the popup would overflow the right edge.
167/// The result is always clamped to stay fully inside `viewport`.
168pub fn position(state: &HoverState, viewport: HoverViewport) -> HoverRect {
169    let ax = state.anchor.col;
170    let ay = state.anchor.row;
171
172    // Compute content dimensions from raw text. Clamp before the u16 casts:
173    // a pathological LSP payload (a 65k-byte line or 65k lines) would
174    // otherwise overflow on the `+ 2` below.
175    let content_w: u16 = state
176        .content
177        .lines()
178        .map(|l| l.len().min(u16::MAX as usize - 2))
179        .max()
180        .unwrap_or(8) as u16;
181    let content_h: u16 = state
182        .content
183        .lines()
184        .count()
185        .clamp(1, u16::MAX as usize - 2) as u16;
186
187    // Total popup size including border (1 cell each side).
188    let popup_w = (content_w + 2)
189        .min(state.max_width)
190        .max(4)
191        .min(viewport.width);
192    let popup_h = (content_h + 2)
193        .min(state.max_height)
194        .max(3)
195        .min(viewport.height);
196
197    // Horizontal: prefer anchor col, shift left if overflowing right edge.
198    let x = if ax.saturating_add(popup_w) <= viewport.width {
199        ax
200    } else {
201        viewport.width.saturating_sub(popup_w)
202    };
203
204    // Vertical: prefer one row below; flip above if no room. Clamp so the
205    // rect stays inside the viewport even for an out-of-range anchor.
206    let below_y = ay.saturating_add(1);
207    let y = if below_y.saturating_add(popup_h) <= viewport.height {
208        below_y
209    } else {
210        ay.saturating_sub(popup_h)
211            .min(viewport.height.saturating_sub(popup_h))
212    };
213
214    HoverRect {
215        x,
216        y,
217        width: popup_w,
218        height: popup_h,
219    }
220}
221
222// ── Tests ─────────────────────────────────────────────────────────────────────
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn vp(w: u16, h: u16) -> HoverViewport {
229        HoverViewport {
230            width: w,
231            height: h,
232        }
233    }
234
235    fn state(content: &str, col: u16, row: u16) -> HoverState {
236        HoverState::new(content.to_string(), HoverAnchor::new(col, row))
237    }
238
239    #[test]
240    fn popup_stays_inside_right_edge() {
241        let s = state("hello world\nsecond line", 75, 5);
242        let r = position(&s, vp(80, 24));
243        assert!(
244            r.x + r.width <= 80,
245            "overflow right: x={} w={} vp=80",
246            r.x,
247            r.width
248        );
249    }
250
251    #[test]
252    fn popup_flips_above_when_no_vertical_room() {
253        let s = state("line1\nline2\nline3", 0, 22);
254        let r = position(&s, vp(80, 24));
255        assert!(r.y < 22, "should be above anchor row 22, got y={}", r.y);
256    }
257
258    #[test]
259    fn popup_shows_below_when_room() {
260        let s = state("short", 0, 0);
261        let r = position(&s, vp(80, 24));
262        assert!(r.y > 0, "should be below anchor at row 0");
263    }
264
265    #[test]
266    fn huge_payload_does_not_panic() {
267        // Regression: a 65k-byte line / 65k-line payload used to overflow the
268        // `content_w + 2` / `content_h + 2` u16 math in debug builds.
269        let long_line = "x".repeat(65_534);
270        let s = state(&long_line, 0, 0);
271        let r = position(&s, vp(80, 24));
272        assert!(r.x + r.width <= 80);
273        assert!(r.y + r.height <= 24);
274
275        let many_lines = "y\n".repeat(65_534);
276        let s = state(&many_lines, 0, 0);
277        let r = position(&s, vp(80, 24));
278        assert!(r.y + r.height <= 24);
279    }
280
281    #[test]
282    fn out_of_range_anchor_is_clamped() {
283        // Anchor outside the viewport must not produce a rect past its edges.
284        let s = state("line1\nline2\nline3", 200, 200);
285        let r = position(&s, vp(80, 24));
286        assert!(r.x + r.width <= 80, "x={} w={}", r.x, r.width);
287        assert!(r.y + r.height <= 24, "y={} h={}", r.y, r.height);
288    }
289
290    #[test]
291    fn scroll_clamps_at_zero() {
292        let mut s = state("x", 0, 0);
293        s.scroll_lines(-10);
294        assert_eq!(s.scroll, 0);
295    }
296
297    #[test]
298    fn scroll_down() {
299        // 5 content lines → max scroll 4, so scrolling 3 lands at 3.
300        let mut s = state("a\nb\nc\nd\ne", 0, 0);
301        s.scroll_lines(3);
302        assert_eq!(s.scroll, 3);
303    }
304
305    #[test]
306    fn scroll_clamps_at_line_count() {
307        // 3 lines → model scroll must not exceed line_count - 1 = 2, so a
308        // huge scroll-down doesn't run away (keeping back-scroll symmetric).
309        let mut s = state("a\nb\nc", 0, 0);
310        s.scroll_lines(100);
311        assert_eq!(s.scroll, 2);
312    }
313
314    #[test]
315    fn is_expired_after_explicit_expiry() {
316        let mut s = state("x", 0, 0);
317        s.expiry = Some(Instant::now() - Duration::from_millis(1));
318        assert!(s.is_expired(Instant::now()));
319    }
320
321    #[test]
322    fn not_expired_fresh() {
323        let s = state("x", 0, 0);
324        assert!(!s.is_expired(Instant::now()));
325    }
326
327    #[test]
328    fn dismissed_counts_as_expired() {
329        let mut s = state("x", 0, 0);
330        s.dismissed = true;
331        assert!(s.is_expired(Instant::now()));
332    }
333}