返回 CodeWhale
hover_hit.rs
根目录 / crates / tui / src / tui / hover_hit.rs
1 //! Shared hover-hit abstraction for interactive terminal surfaces.
2 //!
3 //! Reused by the context menu, transcript cells, diff footers, OSC-8 links,
4 //! code blocks, file references, and tool cards. Keeps a cheap hit-test alive
5 //! while streaming without forcing expensive transcript reflow.
6
7 // Public API surface; hover_layer + mouse_ui consume these primitives.
8
9 use ratatui::{
10 layout::Rect,
11 style::{Color, Modifier, Style},
12 };
13 use unicode_width::UnicodeWidthStr;
14
15 use crate::tui::ocean;
16
17 /// Kind of interactive surface under the pointer.
18 ///
19 /// Surfaces adopt kinds incrementally (link regions first; menu/code/diff as
20 /// they land). Variants stay exhaustive for cursor + aura match arms.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 #[allow(dead_code)] // reserved kinds emit as more surfaces register hits
23 pub enum HoverTargetKind {
24 Plain,
25 Link,
26 Code,
27 Diff,
28 FileRef,
29 ToolCard,
30 MenuRow,
31 DiffAction,
32 }
33
34 /// Result of a hover hit-test.
35 #[derive(Debug, Clone, PartialEq, Eq)]
36 pub struct HoverHit {
37 pub kind: HoverTargetKind,
38 pub area: Rect,
39 /// Optional label for tooltips / copy affordances.
40 pub label: String,
41 /// Whether a hover-only `copy` chip should be shown.
42 pub copyable: bool,
43 }
44
45 /// Aura style for a hovered interactive cell.
46 #[must_use]
47 pub fn hover_aura_style(
48 base_bg: Color,
49 accent: Color,
50 reduced_motion: bool,
51 elapsed_ms: u128,
52 ) -> Style {
53 let amount = if reduced_motion {
54 0.18
55 } else {
56 // Gentle ~1 Hz pulse frozen under reduced motion.
57 let phase = (elapsed_ms % 1_000) as f32 / 1_000.0;
58 let s = (phase * std::f32::consts::TAU).sin();
59 0.14 + s.abs() * 0.08
60 };
61 let bg = ocean::mix_colors(base_bg, accent, amount);
62 Style::default().bg(bg)
63 }
64
65 /// Underline + glow for OSC-8 / file links under the pointer.
66 #[must_use]
67 pub fn link_hover_style(fg: Color, reduced_motion: bool, elapsed_ms: u128) -> Style {
68 let scale = if reduced_motion {
69 1.15
70 } else {
71 let phase = (elapsed_ms % 1_200) as f32 / 1_200.0;
72 1.10 + (phase * std::f32::consts::TAU).sin().abs() * 0.12
73 };
74 Style::default()
75 .fg(ocean::scale_color(fg, scale))
76 .add_modifier(Modifier::UNDERLINED | Modifier::BOLD)
77 }
78
79 /// Hover-only `copy` chip text (display width fixed).
80 #[must_use]
81 pub fn copy_affordance() -> &'static str {
82 "⧉ copy"
83 }
84
85 /// Whether `column,row` hits `area`.
86 #[must_use]
87 pub fn point_in_rect(column: u16, row: u16, area: Option<Rect>) -> bool {
88 let Some(area) = area else {
89 return false;
90 };
91 column >= area.x
92 && column < area.x.saturating_add(area.width)
93 && row >= area.y
94 && row < area.y.saturating_add(area.height)
95 }
96
97 /// Hit-test a list of rectangular targets; returns the topmost match.
98 #[must_use]
99 pub fn hit_test(column: u16, row: u16, targets: &[HoverHit]) -> Option<&HoverHit> {
100 targets
101 .iter()
102 .rev()
103 .find(|t| point_in_rect(column, row, Some(t.area)))
104 }
105
106 /// Preferred terminal cursor shape for a hover target (best-effort hint).
107 #[must_use]
108 pub fn cursor_shape_for(kind: HoverTargetKind) -> &'static str {
109 match kind {
110 HoverTargetKind::Link | HoverTargetKind::FileRef | HoverTargetKind::MenuRow => "pointer",
111 HoverTargetKind::Code | HoverTargetKind::Diff | HoverTargetKind::Plain => "text",
112 HoverTargetKind::ToolCard | HoverTargetKind::DiffAction => "pointer",
113 }
114 }
115
116 /// Build a compact tooltip line that fits `max_width`.
117 #[must_use]
118 pub fn tooltip_line(label: &str, max_width: usize) -> String {
119 let trimmed = label.trim();
120 if UnicodeWidthStr::width(trimmed) <= max_width {
121 return trimmed.to_string();
122 }
123 if max_width <= 3 {
124 return ".".repeat(max_width);
125 }
126 let mut out = String::new();
127 let mut w = 0usize;
128 let limit = max_width.saturating_sub(3);
129 for ch in trimmed.chars() {
130 let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
131 if w + cw > limit {
132 break;
133 }
134 out.push(ch);
135 w += cw;
136 }
137 out.push_str("...");
138 out
139 }
140
141 #[cfg(test)]
142 mod tests {
143 use super::*;
144
145 #[test]
146 fn hit_test_returns_topmost() {
147 let targets = vec![
148 HoverHit {
149 kind: HoverTargetKind::Plain,
150 area: Rect::new(0, 0, 10, 1),
151 label: "a".into(),
152 copyable: false,
153 },
154 HoverHit {
155 kind: HoverTargetKind::Link,
156 area: Rect::new(2, 0, 4, 1),
157 label: "b".into(),
158 copyable: true,
159 },
160 ];
161 let hit = hit_test(3, 0, &targets).expect("hit");
162 assert_eq!(hit.kind, HoverTargetKind::Link);
163 }
164
165 #[test]
166 fn copy_affordance_is_stable() {
167 assert_eq!(copy_affordance(), "⧉ copy");
168 }
169 }
170
170 lines RUST