| 1 | //! Frame-scoped hover registry for transcript / diff / tool surfaces. |
| 2 | #![allow(dead_code)] // Public hover API; surfaces adopt pieces incrementally. |
| 3 | //! |
| 4 | //! Collects hit targets during render, resolves the pointer once, and applies |
| 5 | //! restrained aura / copy / link glow. Reuses [`super::hover_hit`] primitives |
| 6 | //! and context-menu hover-follow patterns without growing `ui.rs`. |
| 7 | |
| 8 | use std::cell::RefCell; |
| 9 | use std::sync::Mutex; |
| 10 | use std::time::Instant; |
| 11 | |
| 12 | use ratatui::{ |
| 13 | buffer::Buffer, |
| 14 | layout::Rect, |
| 15 | style::{Modifier, Style}, |
| 16 | text::{Line, Span}, |
| 17 | }; |
| 18 | |
| 19 | use crate::palette; |
| 20 | use crate::tui::hover_hit::{ |
| 21 | HoverHit, HoverTargetKind, copy_affordance, cursor_shape_for, hit_test, hover_aura_style, |
| 22 | link_hover_style, tooltip_line, |
| 23 | }; |
| 24 | |
| 25 | /// Pointer position from the last mouse move (column, row). |
| 26 | static POINTER: Mutex<Option<(u16, u16)>> = Mutex::new(None); |
| 27 | |
| 28 | // Targets registered for the current frame (thread-local for render path). |
| 29 | thread_local! { |
| 30 | static FRAME_TARGETS: RefCell<Vec<HoverHit>> = const { RefCell::new(Vec::new()) }; |
| 31 | static FRAME_HOVER: RefCell<Option<HoverHit>> = const { RefCell::new(None) }; |
| 32 | static FRAME_START: RefCell<Option<Instant>> = const { RefCell::new(None) }; |
| 33 | } |
| 34 | |
| 35 | /// Clear targets at the start of a draw. |
| 36 | pub fn begin_frame() { |
| 37 | FRAME_TARGETS.with(|t| t.borrow_mut().clear()); |
| 38 | FRAME_HOVER.with(|h| *h.borrow_mut() = None); |
| 39 | FRAME_START.with(|s| *s.borrow_mut() = Some(Instant::now())); |
| 40 | } |
| 41 | |
| 42 | /// Record an interactive region for hit-testing this frame. |
| 43 | pub fn register(hit: HoverHit) { |
| 44 | FRAME_TARGETS.with(|t| t.borrow_mut().push(hit)); |
| 45 | } |
| 46 | |
| 47 | /// Convenience: register a rectangular target. |
| 48 | pub fn register_rect(kind: HoverTargetKind, area: Rect, label: impl Into<String>, copyable: bool) { |
| 49 | if area.width == 0 || area.height == 0 { |
| 50 | return; |
| 51 | } |
| 52 | register(HoverHit { |
| 53 | kind, |
| 54 | area, |
| 55 | label: label.into(), |
| 56 | copyable, |
| 57 | }); |
| 58 | } |
| 59 | |
| 60 | /// Update the shared pointer from mouse motion (call from mouse_ui). |
| 61 | pub fn set_pointer(column: u16, row: u16) { |
| 62 | if let Ok(mut guard) = POINTER.lock() { |
| 63 | *guard = Some((column, row)); |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | /// Clear pointer (e.g. leave alternate screen). |
| 68 | pub fn clear_pointer() { |
| 69 | if let Ok(mut guard) = POINTER.lock() { |
| 70 | *guard = None; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | /// Resolve hover after targets are registered; call once near end of draw. |
| 75 | pub fn resolve_hover() { |
| 76 | let pointer = POINTER.lock().ok().and_then(|g| *g); |
| 77 | let Some((col, row)) = pointer else { |
| 78 | FRAME_HOVER.with(|h| *h.borrow_mut() = None); |
| 79 | return; |
| 80 | }; |
| 81 | FRAME_TARGETS.with(|t| { |
| 82 | let targets = t.borrow(); |
| 83 | let hit = hit_test(col, row, &targets).cloned(); |
| 84 | FRAME_HOVER.with(|h| *h.borrow_mut() = hit); |
| 85 | }); |
| 86 | } |
| 87 | |
| 88 | /// Current hover hit, if any. |
| 89 | #[must_use] |
| 90 | pub fn current_hover() -> Option<HoverHit> { |
| 91 | FRAME_HOVER.with(|h| h.borrow().clone()) |
| 92 | } |
| 93 | |
| 94 | /// Preferred cursor shape for the active hover (best-effort string token). |
| 95 | #[must_use] |
| 96 | pub fn active_cursor_shape() -> Option<&'static str> { |
| 97 | current_hover().map(|h| cursor_shape_for(h.kind)) |
| 98 | } |
| 99 | |
| 100 | /// Elapsed ms since frame begin for pulse math. |
| 101 | fn elapsed_ms() -> u128 { |
| 102 | FRAME_START |
| 103 | .with(|s| s.borrow().map(|t| t.elapsed().as_millis())) |
| 104 | .unwrap_or(0) |
| 105 | } |
| 106 | |
| 107 | /// Apply restrained aura to a hovered rect on the buffer. |
| 108 | pub fn paint_aura( |
| 109 | buf: &mut Buffer, |
| 110 | area: Rect, |
| 111 | accent: ratatui::style::Color, |
| 112 | reduced_motion: bool, |
| 113 | ) { |
| 114 | if area.width == 0 || area.height == 0 { |
| 115 | return; |
| 116 | } |
| 117 | let ms = elapsed_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 | let base = cell.bg; |
| 127 | let style = hover_aura_style(base, accent, reduced_motion, ms); |
| 128 | if let Some(bg) = style.bg { |
| 129 | cell.set_bg(bg); |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | /// Paint OSC-8 / file-ref underline glow on a hovered link span row. |
| 136 | pub fn paint_link_glow( |
| 137 | buf: &mut Buffer, |
| 138 | area: Rect, |
| 139 | fg: ratatui::style::Color, |
| 140 | reduced_motion: bool, |
| 141 | ) { |
| 142 | let ms = elapsed_ms(); |
| 143 | let style = link_hover_style(fg, reduced_motion, ms); |
| 144 | for y in area.y..area.y.saturating_add(area.height) { |
| 145 | for x in area.x..area.x.saturating_add(area.width) { |
| 146 | if x >= buf.area.x.saturating_add(buf.area.width) |
| 147 | || y >= buf.area.y.saturating_add(buf.area.height) |
| 148 | { |
| 149 | continue; |
| 150 | } |
| 151 | let cell = &mut buf[(x, y)]; |
| 152 | if let Some(color) = style.fg { |
| 153 | cell.set_fg(color); |
| 154 | } |
| 155 | cell.modifier.insert(Modifier::UNDERLINED); |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /// Hover-only copy chip line for code/diff/text surfaces. |
| 161 | #[must_use] |
| 162 | pub fn copy_chip_line(max_width: u16) -> Line<'static> { |
| 163 | let text = copy_affordance(); |
| 164 | let budget = usize::from(max_width.max(1)); |
| 165 | let label = if unicode_width::UnicodeWidthStr::width(text) > budget { |
| 166 | "⧉".to_string() |
| 167 | } else { |
| 168 | text.to_string() |
| 169 | }; |
| 170 | Line::from(Span::styled( |
| 171 | label, |
| 172 | Style::default() |
| 173 | .fg(palette::TEXT_HINT) |
| 174 | .add_modifier(Modifier::DIM), |
| 175 | )) |
| 176 | } |
| 177 | |
| 178 | /// Short tooltip for file refs / tool cards when hovered. |
| 179 | #[must_use] |
| 180 | pub fn hover_tooltip(max_width: usize) -> Option<String> { |
| 181 | let hit = current_hover()?; |
| 182 | if hit.label.trim().is_empty() { |
| 183 | return None; |
| 184 | } |
| 185 | match hit.kind { |
| 186 | HoverTargetKind::FileRef | HoverTargetKind::ToolCard | HoverTargetKind::Link => { |
| 187 | Some(tooltip_line(&hit.label, max_width.max(8))) |
| 188 | } |
| 189 | _ => None, |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Apply all hover effects for the resolved target onto `buf`. |
| 194 | pub fn apply_resolved_effects( |
| 195 | buf: &mut Buffer, |
| 196 | reduced_motion: bool, |
| 197 | accent: ratatui::style::Color, |
| 198 | ) { |
| 199 | resolve_hover(); |
| 200 | let Some(hit) = current_hover() else { |
| 201 | return; |
| 202 | }; |
| 203 | match hit.kind { |
| 204 | HoverTargetKind::Link | HoverTargetKind::FileRef => { |
| 205 | paint_link_glow(buf, hit.area, palette::WHALE_ACTION, reduced_motion); |
| 206 | } |
| 207 | HoverTargetKind::Code |
| 208 | | HoverTargetKind::Diff |
| 209 | | HoverTargetKind::ToolCard |
| 210 | | HoverTargetKind::DiffAction |
| 211 | | HoverTargetKind::Plain |
| 212 | | HoverTargetKind::MenuRow => { |
| 213 | paint_aura(buf, hit.area, accent, reduced_motion); |
| 214 | } |
| 215 | } |
| 216 | // Hover-only copy chip on the trailing edge of copyable targets. |
| 217 | if hit.copyable && hit.area.width > 8 { |
| 218 | let chip = copy_affordance(); |
| 219 | let chip_w = unicode_width::UnicodeWidthStr::width(chip) as u16; |
| 220 | if chip_w < hit.area.width { |
| 221 | let x = hit |
| 222 | .area |
| 223 | .x |
| 224 | .saturating_add(hit.area.width.saturating_sub(chip_w + 1)); |
| 225 | let y = hit.area.y; |
| 226 | for (i, ch) in chip.chars().enumerate() { |
| 227 | let cx = x.saturating_add(i as u16); |
| 228 | if cx >= buf.area.x.saturating_add(buf.area.width) { |
| 229 | break; |
| 230 | } |
| 231 | let cell = &mut buf[(cx, y)]; |
| 232 | cell.set_symbol(&ch.to_string()); |
| 233 | cell.set_fg(palette::TEXT_HINT); |
| 234 | cell.modifier.insert(Modifier::DIM); |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #[cfg(test)] |
| 241 | mod tests { |
| 242 | use super::*; |
| 243 | |
| 244 | // Global POINTER is process-wide; serialize tests that touch it. |
| 245 | static HOVER_TEST_LOCK: Mutex<()> = Mutex::new(()); |
| 246 | |
| 247 | #[test] |
| 248 | fn register_and_resolve_hit() { |
| 249 | let _guard = HOVER_TEST_LOCK.lock().unwrap(); |
| 250 | clear_pointer(); |
| 251 | begin_frame(); |
| 252 | set_pointer(5, 2); |
| 253 | register_rect( |
| 254 | HoverTargetKind::Code, |
| 255 | Rect::new(0, 2, 20, 1), |
| 256 | "fn main", |
| 257 | true, |
| 258 | ); |
| 259 | resolve_hover(); |
| 260 | let hit = current_hover().expect("hover"); |
| 261 | assert_eq!(hit.kind, HoverTargetKind::Code); |
| 262 | assert!(hit.copyable); |
| 263 | clear_pointer(); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn tooltip_only_for_file_and_tool() { |
| 268 | let _guard = HOVER_TEST_LOCK.lock().unwrap(); |
| 269 | clear_pointer(); |
| 270 | begin_frame(); |
| 271 | set_pointer(1, 1); |
| 272 | register_rect( |
| 273 | HoverTargetKind::FileRef, |
| 274 | Rect::new(0, 1, 10, 1), |
| 275 | "src/lib.rs", |
| 276 | false, |
| 277 | ); |
| 278 | resolve_hover(); |
| 279 | let tip = hover_tooltip(40).expect("tooltip"); |
| 280 | assert!(tip.contains("lib.rs"), "{tip}"); |
| 281 | clear_pointer(); |
| 282 | } |
| 283 | } |
| 284 |