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) {
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 pub fn line_count(&self) -> usize {
152 self.content.lines().count().max(1)
153 }
154}
155
156pub fn position(state: &HoverState, viewport: HoverViewport) -> HoverRect {
165 let ax = state.anchor.col;
166 let ay = state.anchor.row;
167
168 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 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 let x = if ax + popup_w <= viewport.width {
189 ax
190 } else {
191 viewport.width.saturating_sub(popup_w)
192 };
193
194 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#[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}