| 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 | /// Clear the process-wide pointer between tests. |
| 75 | #[cfg(test)] |
| 76 | pub fn clear_pointer() { |
| 77 | if let Ok(mut guard) = POINTER.lock() { |
| 78 | *guard = None; |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Resolve hover after targets are registered; call once near end of draw. |
| 83 | pub fn resolve_hover() { |
| 84 | let pointer = POINTER.lock().ok().and_then(|g| *g); |
| 85 | let Some((col, row)) = pointer else { |
| 86 | FRAME_HOVER.with(|h| *h.borrow_mut() = None); |
| 87 | return; |
| 88 | }; |
| 89 | FRAME_TARGETS.with(|t| { |
| 90 | let targets = t.borrow(); |
| 91 | let hit = hit_test(col, row, &targets).cloned(); |
| 92 | FRAME_HOVER.with(|h| *h.borrow_mut() = hit); |
| 93 | }); |
| 94 | } |
| 95 | |
| 96 | /// Current hover hit, if any. |
| 97 | #[must_use] |
| 98 | pub fn current_hover() -> Option<HoverHit> { |
| 99 | FRAME_HOVER.with(|h| h.borrow().clone()) |
| 100 | } |
| 101 | |
| 102 | /// Elapsed ms since frame begin for pulse math. |
| 103 | fn elapsed_ms() -> u128 { |
| 104 | FRAME_START |
| 105 | .with(|s| s.borrow().map(|t| t.elapsed().as_millis())) |
| 106 | .unwrap_or(0) |
| 107 | } |
| 108 | |
| 109 | /// Paint OSC-8 / file-ref underline glow on a hovered link span row. |
| 110 | pub fn paint_link_glow( |
| 111 | buf: &mut Buffer, |
| 112 | area: Rect, |
| 113 | fg: ratatui::style::Color, |
| 114 | reduced_motion: bool, |
| 115 | ) { |
| 116 | let ms = elapsed_ms(); |
| 117 | let style = link_hover_style(fg, reduced_motion, ms); |
| 118 | for y in area.y..area.y.saturating_add(area.height) { |
| 119 | for x in area.x..area.x.saturating_add(area.width) { |
| 120 | if x >= buf.area.x.saturating_add(buf.area.width) |
| 121 | || y >= buf.area.y.saturating_add(buf.area.height) |
| 122 | { |
| 123 | continue; |
| 124 | } |
| 125 | let cell = &mut buf[(x, y)]; |
| 126 | if let Some(color) = style.fg { |
| 127 | cell.set_fg(color); |
| 128 | } |
| 129 | cell.modifier.insert(Modifier::UNDERLINED); |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | /// Apply all hover effects for the resolved target onto `buf`. |
| 135 | pub fn apply_resolved_effects(buf: &mut Buffer, reduced_motion: bool, theme: &palette::UiTheme) { |
| 136 | resolve_hover(); |
| 137 | let Some(hit) = current_hover() else { |
| 138 | return; |
| 139 | }; |
| 140 | match hit.kind { |
| 141 | HoverTargetKind::Link => { |
| 142 | paint_link_glow(buf, hit.area, theme.accent_primary, reduced_motion); |
| 143 | // Hover-only copy chip on the trailing edge of copyable targets. |
| 144 | if hit.copyable && hit.area.width > 8 { |
| 145 | let chip = copy_affordance(); |
| 146 | let chip_w = UnicodeWidthStr::width(chip) as u16; |
| 147 | if chip_w < hit.area.width { |
| 148 | let x = hit |
| 149 | .area |
| 150 | .x |
| 151 | .saturating_add(hit.area.width.saturating_sub(chip_w + 1)); |
| 152 | let y = hit.area.y; |
| 153 | for (i, ch) in chip.chars().enumerate() { |
| 154 | let cx = x.saturating_add(i as u16); |
| 155 | if cx >= buf.area.x.saturating_add(buf.area.width) { |
| 156 | break; |
| 157 | } |
| 158 | let cell = &mut buf[(cx, y)]; |
| 159 | cell.set_symbol(&ch.to_string()); |
| 160 | cell.set_fg(theme.text_hint); |
| 161 | cell.modifier.insert(Modifier::DIM); |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | } |
| 166 | HoverTargetKind::TruncatedText => { |
| 167 | paint_link_glow(buf, hit.area, theme.accent_primary, true); |
| 168 | paint_full_text_popover(buf, &hit, theme); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | fn full_text_popover_area(hit: &HoverHit, bounds: Rect) -> Option<Rect> { |
| 174 | if bounds.width < 4 || bounds.height < 3 || hit.label.is_empty() { |
| 175 | return None; |
| 176 | } |
| 177 | |
| 178 | let widest_line = hit |
| 179 | .label |
| 180 | .lines() |
| 181 | .map(UnicodeWidthStr::width) |
| 182 | .max() |
| 183 | .unwrap_or(1); |
| 184 | let max_width = bounds.width.min(72); |
| 185 | let width = u16::try_from(widest_line.saturating_add(2)) |
| 186 | .unwrap_or(u16::MAX) |
| 187 | .clamp(4, max_width); |
| 188 | let content_width = width.saturating_sub(2).max(1); |
| 189 | let paragraph = Paragraph::new(hit.label.as_str()).wrap(Wrap { trim: false }); |
| 190 | let content_height = u16::try_from(paragraph.line_count(content_width)) |
| 191 | .unwrap_or(u16::MAX) |
| 192 | .max(1); |
| 193 | let height = content_height.saturating_add(2).min(bounds.height); |
| 194 | |
| 195 | let rightmost_x = bounds.x.saturating_add(bounds.width).saturating_sub(width); |
| 196 | let x = hit.area.x.clamp(bounds.x, rightmost_x); |
| 197 | let bounds_bottom = bounds.y.saturating_add(bounds.height); |
| 198 | let below_y = hit.area.y.saturating_add(hit.area.height); |
| 199 | let y = if below_y.saturating_add(height) <= bounds_bottom { |
| 200 | below_y |
| 201 | } else { |
| 202 | hit.area.y.saturating_sub(height).max(bounds.y) |
| 203 | }; |
| 204 | Some(Rect::new(x, y, width, height)) |
| 205 | } |
| 206 | |
| 207 | fn paint_full_text_popover(buf: &mut Buffer, hit: &HoverHit, theme: &palette::UiTheme) { |
| 208 | let Some(area) = full_text_popover_area(hit, buf.area) else { |
| 209 | return; |
| 210 | }; |
| 211 | Clear.render(area, buf); |
| 212 | Paragraph::new(hit.label.as_str()) |
| 213 | .style(Style::default().fg(theme.text_body).bg(theme.elevated_bg)) |
| 214 | .block( |
| 215 | Block::default() |
| 216 | .borders(Borders::ALL) |
| 217 | .border_style(Style::default().fg(theme.accent_primary)) |
| 218 | .style(Style::default().bg(theme.elevated_bg)), |
| 219 | ) |
| 220 | .wrap(Wrap { trim: false }) |
| 221 | .render(area, buf); |
| 222 | } |
| 223 | |
| 224 | #[cfg(test)] |
| 225 | mod tests { |
| 226 | use super::*; |
| 227 | |
| 228 | #[test] |
| 229 | fn register_and_resolve_hit() { |
| 230 | let _guard = HOVER_TEST_LOCK.lock().unwrap(); |
| 231 | clear_pointer(); |
| 232 | begin_frame(); |
| 233 | set_pointer(5, 2); |
| 234 | register_rect( |
| 235 | HoverTargetKind::Link, |
| 236 | Rect::new(0, 2, 20, 1), |
| 237 | "fn main", |
| 238 | true, |
| 239 | ); |
| 240 | resolve_hover(); |
| 241 | let hit = current_hover().expect("hover"); |
| 242 | assert_eq!(hit.kind, HoverTargetKind::Link); |
| 243 | assert!(hit.copyable); |
| 244 | clear_pointer(); |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn truncated_text_popover_wraps_and_stays_inside_bottom_edge() { |
| 249 | let hit = HoverHit { |
| 250 | kind: HoverTargetKind::TruncatedText, |
| 251 | area: Rect::new(8, 8, 22, 1), |
| 252 | label: "完整的中文说明 keeps the full underlying copy".into(), |
| 253 | copyable: false, |
| 254 | }; |
| 255 | let bounds = Rect::new(0, 0, 32, 10); |
| 256 | let area = full_text_popover_area(&hit, bounds).expect("popover"); |
| 257 | assert!( |
| 258 | area.y < hit.area.y, |
| 259 | "bottom row should place above: {area:?}" |
| 260 | ); |
| 261 | assert!(area.right() <= bounds.right()); |
| 262 | assert!(area.bottom() <= bounds.bottom()); |
| 263 | |
| 264 | let mut buf = Buffer::empty(bounds); |
| 265 | paint_full_text_popover(&mut buf, &hit, &palette::UI_THEME); |
| 266 | let rendered = buf |
| 267 | .content |
| 268 | .iter() |
| 269 | .map(|cell| cell.symbol()) |
| 270 | .collect::<String>(); |
| 271 | for glyph in ['完', '整', '中', '文', '说', '明'] { |
| 272 | assert!(rendered.contains(glyph), "missing {glyph:?}: {rendered:?}"); |
| 273 | } |
| 274 | assert!( |
| 275 | rendered.contains("underlying copy"), |
| 276 | "rendered: {rendered:?}" |
| 277 | ); |
| 278 | } |
| 279 | } |
| 280 |