返回 CodeWhale
hover_layer.rs
根目录 / crates / tui / src / tui / hover_layer.rs
1 //! Frame-scoped hover registry for transcript / diff / tool surfaces.
2 //!
3 //! Collects hit targets during render, resolves the pointer once, and applies
4 //! restrained aura / copy / link glow. Reuses [`super::hover_hit`] primitives
5 //! and context-menu hover-follow patterns without growing `ui.rs`.
6
7 use std::cell::RefCell;
8 use std::sync::Mutex;
9 use std::time::Instant;
10
11 use ratatui::{
12 buffer::Buffer,
13 layout::Rect,
14 style::{Modifier, Style},
15 widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap},
16 };
17 use unicode_width::UnicodeWidthStr;
18
19 use crate::tui::hover_hit::{
20 HoverHit, HoverTargetKind, copy_affordance, hit_test, link_hover_style,
21 };
22 use codewhale_palette as palette;
23
24 /// Pointer position from the last mouse move (column, row).
25 static POINTER: Mutex<Option<(u16, u16)>> = Mutex::new(None);
26
27 #[cfg(test)]
28 pub static HOVER_TEST_LOCK: Mutex<()> = Mutex::new(());
29
30 // Targets registered for the current frame (thread-local for render path).
31 thread_local! {
32 static FRAME_TARGETS: RefCell<Vec<HoverHit>> = const { RefCell::new(Vec::new()) };
33 static FRAME_HOVER: RefCell<Option<HoverHit>> = const { RefCell::new(None) };
34 static FRAME_START: RefCell<Option<Instant>> = const { RefCell::new(None) };
35 }
36
37 /// Clear targets at the start of a draw.
38 pub fn begin_frame() {
39 FRAME_TARGETS.with(|t| t.borrow_mut().clear());
40 FRAME_HOVER.with(|h| *h.borrow_mut() = None);
41 FRAME_START.with(|s| *s.borrow_mut() = Some(Instant::now()));
42 }
43
44 /// Record an interactive region for hit-testing this frame.
45 pub fn register(hit: HoverHit) {
46 FRAME_TARGETS.with(|t| t.borrow_mut().push(hit));
47 }
48
49 #[cfg(test)]
50 pub fn registered_targets() -> Vec<HoverHit> {
51 FRAME_TARGETS.with(|targets| targets.borrow().clone())
52 }
53
54 /// Convenience: register a rectangular target.
55 pub fn register_rect(kind: HoverTargetKind, area: Rect, label: impl Into<String>, copyable: bool) {
56 if area.width == 0 || area.height == 0 {
57 return;
58 }
59 register(HoverHit {
60 kind,
61 area,
62 label: label.into(),
63 copyable,
64 });
65 }
66
67 /// Update the shared pointer from mouse motion (call from mouse_ui).
68 pub fn set_pointer(column: u16, row: u16) {
69 if let Ok(mut guard) = POINTER.lock() {
70 *guard = Some((column, row));
71 }
72 }
73
74 /// Forget the pointer. Called on terminal `FocusLost`: terminals stop
75 /// reporting motion once the window is inactive, so the last position would
76 /// otherwise keep a hover glow painted until the mouse moves again (#6503).
77 /// Tests also use it to reset the process-wide pointer.
78 pub fn clear_pointer() {
79 if let Ok(mut guard) = POINTER.lock() {
80 *guard = None;
81 }
82 }
83
84 /// Resolve hover after targets are registered; call once near end of draw.
85 pub fn resolve_hover() {
86 let pointer = POINTER.lock().ok().and_then(|g| *g);
87 let Some((col, row)) = pointer else {
88 FRAME_HOVER.with(|h| *h.borrow_mut() = None);
89 return;
90 };
91 FRAME_TARGETS.with(|t| {
92 let targets = t.borrow();
93 let hit = hit_test(col, row, &targets).cloned();
94 FRAME_HOVER.with(|h| *h.borrow_mut() = hit);
95 });
96 }
97
98 /// Current hover hit, if any.
99 #[must_use]
100 pub fn current_hover() -> Option<HoverHit> {
101 FRAME_HOVER.with(|h| h.borrow().clone())
102 }
103
104 /// Elapsed ms since frame begin for pulse math.
105 fn elapsed_ms() -> u128 {
106 FRAME_START
107 .with(|s| s.borrow().map(|t| t.elapsed().as_millis()))
108 .unwrap_or(0)
109 }
110
111 /// Paint OSC-8 / file-ref underline glow on a hovered link span row.
112 ///
113 /// Underline is a text-line decoration, so only a one-row target gets it. A
114 /// multi-row control — the 3x3 jump-to-latest button, a workbar panel — is a
115 /// box: underlining every row ruled a line through its borders and under its
116 /// bottom edge (#6697). Those keep the foreground glow alone.
117 pub fn paint_link_glow(
118 buf: &mut Buffer,
119 area: Rect,
120 fg: ratatui::style::Color,
121 reduced_motion: bool,
122 ) {
123 let ms = elapsed_ms();
124 let style = link_hover_style(fg, reduced_motion, ms);
125 let underline = area.height == 1;
126 for y in area.y..area.y.saturating_add(area.height) {
127 for x in area.x..area.x.saturating_add(area.width) {
128 if x >= buf.area.x.saturating_add(buf.area.width)
129 || y >= buf.area.y.saturating_add(buf.area.height)
130 {
131 continue;
132 }
133 let cell = &mut buf[(x, y)];
134 if let Some(color) = style.fg {
135 cell.set_fg(color);
136 }
137 if underline {
138 cell.modifier.insert(Modifier::UNDERLINED);
139 }
140 }
141 }
142 }
143
144 /// Apply all hover effects for the resolved target onto `buf`.
145 pub fn apply_resolved_effects(buf: &mut Buffer, reduced_motion: bool, theme: &palette::UiTheme) {
146 resolve_hover();
147 let Some(hit) = current_hover() else {
148 return;
149 };
150 match hit.kind {
151 HoverTargetKind::Link => {
152 paint_link_glow(buf, hit.area, theme.accent_primary, reduced_motion);
153 // Hover-only copy chip on the trailing edge of copyable targets.
154 if hit.copyable && hit.area.width > 8 {
155 let chip = copy_affordance();
156 let chip_w = UnicodeWidthStr::width(chip) as u16;
157 if chip_w < hit.area.width {
158 let x = hit
159 .area
160 .x
161 .saturating_add(hit.area.width.saturating_sub(chip_w + 1));
162 let y = hit.area.y;
163 for (i, ch) in chip.chars().enumerate() {
164 let cx = x.saturating_add(i as u16);
165 if cx >= buf.area.x.saturating_add(buf.area.width) {
166 break;
167 }
168 let cell = &mut buf[(cx, y)];
169 cell.set_symbol(&ch.to_string());
170 cell.set_fg(theme.text_hint);
171 cell.modifier.insert(Modifier::DIM);
172 }
173 }
174 }
175 }
176 HoverTargetKind::TruncatedText => {
177 paint_link_glow(buf, hit.area, theme.accent_primary, true);
178 paint_full_text_popover(buf, &hit, theme);
179 }
180 }
181 }
182
183 fn full_text_popover_area(hit: &HoverHit, bounds: Rect) -> Option<Rect> {
184 if bounds.width < 4 || bounds.height < 3 || hit.label.is_empty() {
185 return None;
186 }
187
188 let widest_line = hit
189 .label
190 .lines()
191 .map(UnicodeWidthStr::width)
192 .max()
193 .unwrap_or(1);
194 let max_width = bounds.width.min(72);
195 let width = u16::try_from(widest_line.saturating_add(2))
196 .unwrap_or(u16::MAX)
197 .clamp(4, max_width);
198 let content_width = width.saturating_sub(2).max(1);
199 let paragraph = Paragraph::new(hit.label.as_str()).wrap(Wrap { trim: false });
200 let content_height = u16::try_from(paragraph.line_count(content_width))
201 .unwrap_or(u16::MAX)
202 .max(1);
203 let height = content_height.saturating_add(2).min(bounds.height);
204
205 let rightmost_x = bounds.x.saturating_add(bounds.width).saturating_sub(width);
206 let x = hit.area.x.clamp(bounds.x, rightmost_x);
207 let bounds_bottom = bounds.y.saturating_add(bounds.height);
208 let below_y = hit.area.y.saturating_add(hit.area.height);
209 let y = if below_y.saturating_add(height) <= bounds_bottom {
210 below_y
211 } else {
212 hit.area.y.saturating_sub(height).max(bounds.y)
213 };
214 Some(Rect::new(x, y, width, height))
215 }
216
217 fn paint_full_text_popover(buf: &mut Buffer, hit: &HoverHit, theme: &palette::UiTheme) {
218 let Some(area) = full_text_popover_area(hit, buf.area) else {
219 return;
220 };
221 Clear.render(area, buf);
222 Paragraph::new(hit.label.as_str())
223 .style(Style::default().fg(theme.text_body).bg(theme.elevated_bg))
224 .block(
225 Block::default()
226 .borders(Borders::ALL)
227 .border_style(Style::default().fg(theme.accent_primary))
228 .style(Style::default().bg(theme.elevated_bg)),
229 )
230 .wrap(Wrap { trim: false })
231 .render(area, buf);
232 }
233
234 #[cfg(test)]
235 mod tests {
236 use super::*;
237
238 #[test]
239 fn register_and_resolve_hit() {
240 let _guard = HOVER_TEST_LOCK.lock().unwrap();
241 clear_pointer();
242 begin_frame();
243 set_pointer(5, 2);
244 register_rect(
245 HoverTargetKind::Link,
246 Rect::new(0, 2, 20, 1),
247 "fn main",
248 true,
249 );
250 resolve_hover();
251 let hit = current_hover().expect("hover");
252 assert_eq!(hit.kind, HoverTargetKind::Link);
253 assert!(hit.copyable);
254 clear_pointer();
255 }
256
257 /// #6697: hovering the 3x3 jump-to-latest button drew a horizontal rule
258 /// under each of its rows. A box control glows; only a one-row link
259 /// underlines.
260 #[test]
261 fn multi_row_button_hover_glows_without_underline_rules() {
262 let _guard = HOVER_TEST_LOCK.lock().unwrap();
263 let theme = palette::UI_THEME;
264 let glow = link_hover_style(theme.accent_primary, true, 0)
265 .fg
266 .expect("glow colour");
267 let mut buf = Buffer::empty(Rect::new(0, 0, 8, 5));
268 let button = Rect::new(4, 1, 3, 3);
269 Block::default()
270 .borders(Borders::ALL)
271 .border_type(ratatui::widgets::BorderType::Rounded)
272 .render(button, &mut buf);
273 let link = Rect::new(0, 0, 3, 1);
274
275 for (target, pointer) in [(button, (5, 2)), (link, (1, 0))] {
276 clear_pointer();
277 begin_frame();
278 set_pointer(pointer.0, pointer.1);
279 register_rect(HoverTargetKind::Link, target, "control", false);
280 apply_resolved_effects(&mut buf, true, &theme);
281 }
282 clear_pointer();
283
284 for y in button.y..button.bottom() {
285 for x in button.x..button.right() {
286 let cell = &buf[(x, y)];
287 assert!(
288 !cell.modifier.contains(Modifier::UNDERLINED),
289 "button cell ({x},{y}) {:?} must not be underlined",
290 cell.symbol()
291 );
292 assert_eq!(cell.fg, glow, "button cell ({x},{y}) keeps the glow");
293 }
294 }
295 for x in link.x..link.right() {
296 assert!(buf[(x, 0)].modifier.contains(Modifier::UNDERLINED));
297 }
298 }
299
300 #[test]
301 fn truncated_text_popover_wraps_and_stays_inside_bottom_edge() {
302 let hit = HoverHit {
303 kind: HoverTargetKind::TruncatedText,
304 area: Rect::new(8, 8, 22, 1),
305 label: "完整的中文说明 keeps the full underlying copy".into(),
306 copyable: false,
307 };
308 let bounds = Rect::new(0, 0, 32, 10);
309 let area = full_text_popover_area(&hit, bounds).expect("popover");
310 assert!(
311 area.y < hit.area.y,
312 "bottom row should place above: {area:?}"
313 );
314 assert!(area.right() <= bounds.right());
315 assert!(area.bottom() <= bounds.bottom());
316
317 let mut buf = Buffer::empty(bounds);
318 paint_full_text_popover(&mut buf, &hit, &palette::UI_THEME);
319 let rendered = buf
320 .content
321 .iter()
322 .map(|cell| cell.symbol())
323 .collect::<String>();
324 for glyph in ['完', '整', '中', '文', '说', '明'] {
325 assert!(rendered.contains(glyph), "missing {glyph:?}: {rendered:?}");
326 }
327 assert!(
328 rendered.contains("underlying copy"),
329 "rendered: {rendered:?}"
330 );
331 }
332 }
333
333 lines RUST