fresh/view/
reference_highlight_overlay.rs1use crate::model::buffer::Buffer;
8use crate::model::marker::MarkerList;
9use crate::primitives::reference_highlighter::ReferenceHighlighter;
10use crate::view::overlay::{Overlay, OverlayFace, OverlayManager, OverlayNamespace};
11use ratatui::style::Color;
12use std::time::{Duration, Instant};
13
14pub const DEFAULT_DEBOUNCE_MS: u64 = 150;
16
17pub fn reference_highlight_namespace() -> OverlayNamespace {
19 OverlayNamespace::from_string("reference-highlight".to_string())
20}
21
22pub struct ReferenceHighlightOverlay {
27 current_word: Option<String>,
29 pending_word: Option<String>,
31 word_changed_at: Option<Instant>,
33 debounce_delay: Duration,
35 pub enabled: bool,
37}
38
39impl ReferenceHighlightOverlay {
40 pub fn new() -> Self {
42 Self {
43 current_word: None,
44 pending_word: None,
45 word_changed_at: None,
46 debounce_delay: Duration::from_millis(DEFAULT_DEBOUNCE_MS),
47 enabled: true,
48 }
49 }
50
51 pub fn with_debounce(delay_ms: u64) -> Self {
53 Self {
54 debounce_delay: Duration::from_millis(delay_ms),
55 ..Self::new()
56 }
57 }
58
59 #[allow(clippy::too_many_arguments)]
68 pub fn update(
69 &mut self,
70 buffer: &Buffer,
71 overlays: &mut OverlayManager,
72 marker_list: &mut MarkerList,
73 highlighter: &mut ReferenceHighlighter,
74 cursor_position: usize,
75 viewport_start: usize,
76 viewport_end: usize,
77 context_bytes: usize,
78 highlight_color: Color,
79 ) -> bool {
80 if !self.enabled {
81 return false;
82 }
83
84 let now = Instant::now();
85
86 let word_under_cursor = get_word_at_position(buffer, cursor_position);
88
89 let word_changed = word_under_cursor != self.pending_word;
91
92 if word_changed {
93 self.word_changed_at = Some(now);
95 self.pending_word = word_under_cursor;
96 return false;
98 }
99
100 if let Some(changed_at) = self.word_changed_at {
102 if now.duration_since(changed_at) >= self.debounce_delay {
103 self.current_word = self.pending_word.clone();
105 self.word_changed_at = None;
106
107 self.apply_highlights(
108 buffer,
109 overlays,
110 marker_list,
111 highlighter,
112 cursor_position,
113 viewport_start,
114 viewport_end,
115 context_bytes,
116 highlight_color,
117 );
118 return true;
119 }
120 }
121
122 false
123 }
124
125 #[allow(clippy::too_many_arguments)]
127 fn apply_highlights(
128 &self,
129 buffer: &Buffer,
130 overlays: &mut OverlayManager,
131 marker_list: &mut MarkerList,
132 highlighter: &mut ReferenceHighlighter,
133 cursor_position: usize,
134 viewport_start: usize,
135 viewport_end: usize,
136 context_bytes: usize,
137 highlight_color: Color,
138 ) {
139 let ns = reference_highlight_namespace();
140
141 overlays.clear_namespace(&ns, marker_list);
143
144 if self.current_word.is_none() {
146 return;
147 }
148
149 highlighter.highlight_color = highlight_color;
151 let spans = highlighter.highlight_occurrences(
152 buffer,
153 cursor_position,
154 viewport_start,
155 viewport_end,
156 context_bytes,
157 );
158
159 for span in spans {
161 let face = OverlayFace::Background { color: span.color };
162 let overlay = Overlay::with_namespace(marker_list, span.range, face, ns.clone())
163 .with_priority_value(5) .with_theme_key("ui.semantic_highlight_bg");
165
166 overlays.add(overlay);
167 }
168 }
169
170 pub fn needs_redraw(&self) -> Option<Duration> {
172 self.word_changed_at.map(|changed_at| {
173 let elapsed = changed_at.elapsed();
174 if elapsed >= self.debounce_delay {
175 Duration::ZERO
176 } else {
177 self.debounce_delay - elapsed
178 }
179 })
180 }
181
182 pub fn clear(&mut self, overlays: &mut OverlayManager, marker_list: &mut MarkerList) {
184 let ns = reference_highlight_namespace();
185 overlays.clear_namespace(&ns, marker_list);
186 self.current_word = None;
187 self.pending_word = None;
188 self.word_changed_at = None;
189 }
190
191 pub fn is_debouncing(&self) -> bool {
193 self.word_changed_at.is_some()
194 }
195
196 pub fn debounce_delay(&self) -> Duration {
198 self.debounce_delay
199 }
200}
201
202impl Default for ReferenceHighlightOverlay {
203 fn default() -> Self {
204 Self::new()
205 }
206}
207
208fn get_word_at_position(buffer: &crate::model::buffer::Buffer, position: usize) -> Option<String> {
210 use crate::primitives::word_navigation::{find_word_end, find_word_start, is_word_char};
211
212 let buf_len = buffer.len();
213 if position > buf_len {
214 return None;
215 }
216
217 let is_on_word = if position < buf_len {
219 let byte_at_pos = buffer.slice_bytes(position..position + 1);
220 byte_at_pos
221 .first()
222 .map(|&b| is_word_char(b))
223 .unwrap_or(false)
224 } else {
225 false
226 };
227
228 if !is_on_word {
229 return None;
230 }
231
232 let start = find_word_start(buffer, position);
234 let end = find_word_end(buffer, position);
235
236 if start < end {
237 let word_bytes = buffer.slice_bytes(start..end);
238 std::str::from_utf8(&word_bytes).ok().map(|s| s.to_string())
239 } else {
240 None
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::model::buffer::Buffer;
248
249 #[test]
250 fn test_get_word_at_position() {
251 let buffer = Buffer::from_str_test("hello world test");
252
253 let word = get_word_at_position(&buffer, 2);
255 assert_eq!(word, Some("hello".to_string()));
256
257 let word = get_word_at_position(&buffer, 5);
259 assert_eq!(word, None);
260
261 let word = get_word_at_position(&buffer, 6);
263 assert_eq!(word, Some("world".to_string()));
264 }
265}