返回 CodeWhale
hover_layer.rs
根目录 / crates / tui / src / tui / hover_layer.rs
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
284 lines RUST