1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
//! Hover popup widget for LSP `textDocument/hover` results (Phase 5 mouse support).
//!
//! Displays multi-line text (markdown stripped by the LSP glue layer) in a
//! floating box anchored at the mouse position. No interactive elements —
//! dismissed by mouse move, any key press, or an 8-second auto-fade.
use std::time::Instant;
use ratatui::{
Frame,
layout::Rect,
style::Style,
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
/// A floating popup that displays multi-line text (typically markdown from
/// `textDocument/hover`). Sits above the editor, dismissed by mouse move or
/// any key press. No interactive elements.
pub struct HoverPopup {
/// Raw (already-stripped) hover text.
pub content: String,
/// (col, row) cell where the mouse rested — used for anchoring.
pub anchor: (u16, u16),
/// Maximum width of the popup (including border).
pub max_width: u16,
/// Maximum height of the popup (including border).
pub max_height: u16,
/// Instant the popup was first shown — used for 8-second auto-fade.
pub displayed_at: Instant,
}
/// Duration before a stationary hover popup is automatically dismissed.
pub const HOVER_AUTO_FADE: std::time::Duration = std::time::Duration::from_secs(8);
impl HoverPopup {
/// Construct a new popup anchored at `anchor` (terminal cell col, row).
pub fn new(content: String, anchor: (u16, u16)) -> Self {
Self {
content,
anchor,
max_width: 60,
max_height: 12,
displayed_at: Instant::now(),
}
}
/// Returns `true` when the 8-second auto-fade deadline has passed.
pub fn is_expired(&self) -> bool {
self.displayed_at.elapsed() >= HOVER_AUTO_FADE
}
/// Compute the on-screen rect, clamping to stay inside `screen`.
///
/// Preferred position: one row below and at the same column as `anchor`.
/// If there is not enough vertical room below, flip so the popup renders
/// above the anchor row. Horizontally, shift left if the popup would
/// overflow the right edge.
pub fn bounding_rect(&self, screen: Rect) -> Rect {
let (ax, ay) = self.anchor;
// Compute content dimensions.
let content_w = self.content_width();
let content_h = self.content_height();
// Total popup size including border (1 cell each side).
let popup_w = (content_w + 2).min(self.max_width).max(4);
let popup_h = (content_h + 2).min(self.max_height).max(3);
// Horizontal: prefer anchor col, shift left if overflowing.
let x = if ax + popup_w <= screen.x + screen.width {
ax
} else {
(screen.x + screen.width).saturating_sub(popup_w)
};
// Vertical: prefer one row below anchor; flip above if no room.
let below_y = ay.saturating_add(1);
let y = if below_y + popup_h <= screen.y + screen.height {
below_y
} else {
// Not enough room below — render above the anchor.
ay.saturating_sub(popup_h)
};
Rect {
x,
y,
width: popup_w,
height: popup_h,
}
}
/// Render the popup into `frame`. `screen` is the full terminal area.
pub fn render(&self, frame: &mut Frame, screen: Rect, theme: &crate::theme::AppTheme) {
let rect = self.bounding_rect(screen);
frame.render_widget(Clear, rect);
let ui = &theme.ui;
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(ui.border_active))
.title(" hover ");
let inner = block.inner(rect);
frame.render_widget(block, rect);
let para = Paragraph::new(self.content.clone())
.style(Style::default().fg(ui.text).bg(ui.panel_bg))
.wrap(Wrap { trim: false });
frame.render_widget(para, inner);
}
/// Width of the widest line in `content` (capped at `max_width - 2`).
fn content_width(&self) -> u16 {
let max_inner = self.max_width.saturating_sub(2);
self.content
.lines()
.map(|l| l.len() as u16)
.max()
.unwrap_or(8)
.min(max_inner)
}
/// Number of wrapped display lines for `content` given `max_width - 2`
/// inner space, capped at `max_height - 2`.
fn content_height(&self) -> u16 {
let inner_w = self.max_width.saturating_sub(2).max(1) as usize;
let max_inner = self.max_height.saturating_sub(2).max(1) as usize;
let mut rows = 0usize;
for line in self.content.lines() {
// Each source line occupies at least one display row; long lines
// wrap into additional rows.
let chars = line.len();
let wrapped = if chars == 0 {
1
} else {
chars.div_ceil(inner_w)
};
rows += wrapped;
if rows >= max_inner {
return max_inner as u16;
}
}
rows.max(1) as u16
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn screen(w: u16, h: u16) -> Rect {
Rect {
x: 0,
y: 0,
width: w,
height: h,
}
}
#[test]
fn bounding_rect_stays_on_screen_right_edge() {
// Anchor near the right edge; popup should shift left to stay on screen.
let popup = HoverPopup::new("hello world\nsecond line".into(), (75, 5));
// popup.max_width = 60, content_width ~= 11, so popup_w = 13
let r = popup.bounding_rect(screen(80, 24));
assert!(
r.x + r.width <= 80,
"popup right edge {} overflows screen width 80",
r.x + r.width
);
}
#[test]
fn bounding_rect_flips_when_no_vertical_room() {
// Anchor near the bottom — popup must render above the anchor.
let popup = HoverPopup::new("line1\nline2\nline3".into(), (0, 22));
let r = popup.bounding_rect(screen(80, 24));
// With anchor at row 22 in a 24-row screen: below_y = 23, popup_h >= 5
// (3 content + 2 border). 23 + 5 > 24 → must flip above.
assert!(
r.y < 22,
"popup y {} should be above anchor row 22 when near bottom",
r.y
);
}
}