| 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 |