1use std::time::{Duration, Instant};
21
22pub const HOVER_AUTO_FADE: Duration = Duration::from_secs(8);
26
27pub const DEFAULT_MAX_WIDTH: u16 = 62;
29
30pub const DEFAULT_MAX_HEIGHT: u16 = 14;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct HoverAnchor {
39 pub col: u16,
41 pub row: u16,
43}
44
45impl HoverAnchor {
46 pub fn new(col: u16, row: u16) -> Self {
48 Self { col, row }
49 }
50}
51
52#[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 pub fn new(width: u16, height: u16) -> Self {
63 Self { width, height }
64 }
65}
66
67#[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 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#[non_exhaustive]
93pub struct HoverState {
94 pub content: String,
96 pub anchor: HoverAnchor,
98 pub dismissed: bool,
100 pub scroll: usize,
102 pub displayed_at: Instant,
104 pub expiry: Option<Instant>,
106 pub max_width: u16,
108 pub max_height: u16,
110}
111
112impl HoverState {
113 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 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 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 pub fn line_count(&self) -> usize {
156 self.content.lines().count().max(1)
157 }
158}
159
160pub fn position(state: &HoverState, viewport: HoverViewport) -> HoverRect {
169 let ax = state.anchor.col;
170 let ay = state.anchor.row;
171
172 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 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 let x = if ax.saturating_add(popup_w) <= viewport.width {
199 ax
200 } else {
201 viewport.width.saturating_sub(popup_w)
202 };
203
204 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#[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 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 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 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 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}