Skip to main content

fresh/view/
reference_highlight_overlay.rs

1//! Reference highlighting using the overlay system
2//!
3//! This module manages word occurrence highlighting through overlays that
4//! automatically adjust their positions when text is edited. Unlike the
5//! old cache-based approach, overlays use markers that move with the text.
6
7use 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
14/// Default debounce delay for reference highlighting (150ms)
15pub const DEFAULT_DEBOUNCE_MS: u64 = 150;
16
17/// Namespace for reference highlight overlays
18pub fn reference_highlight_namespace() -> OverlayNamespace {
19    OverlayNamespace::from_string("reference-highlight".to_string())
20}
21
22/// Manager for reference highlight overlays
23///
24/// Tracks the current word under cursor and manages overlays that highlight
25/// all occurrences. Overlays automatically adjust positions via markers.
26pub struct ReferenceHighlightOverlay {
27    /// The word currently highlighted (overlays exist for this word)
28    current_word: Option<String>,
29    /// The word we're waiting to highlight (pending debounce)
30    pending_word: Option<String>,
31    /// When cursor moved to a different word (for debouncing)
32    word_changed_at: Option<Instant>,
33    /// Debounce delay before updating highlights
34    debounce_delay: Duration,
35    /// Whether highlighting is enabled
36    pub enabled: bool,
37}
38
39impl ReferenceHighlightOverlay {
40    /// Create a new reference highlight overlay manager
41    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    /// Create with custom debounce delay
52    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    /// Update reference highlights based on cursor position
60    ///
61    /// This should be called on each render. It will:
62    /// 1. Check if cursor is on a different word
63    /// 2. Debounce rapid cursor movements
64    /// 3. Update overlays when debounce period elapses
65    ///
66    /// Returns true if overlays were updated
67    #[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        // Get the word under cursor
87        let word_under_cursor = get_word_at_position(buffer, cursor_position);
88
89        // Check if word changed from what we're tracking
90        let word_changed = word_under_cursor != self.pending_word;
91
92        if word_changed {
93            // Word changed - record time and new pending word
94            self.word_changed_at = Some(now);
95            self.pending_word = word_under_cursor;
96            // Keep showing current overlays (they auto-adjust via markers)
97            return false;
98        }
99
100        // Word is same as pending - check if we should apply
101        if let Some(changed_at) = self.word_changed_at {
102            if now.duration_since(changed_at) >= self.debounce_delay {
103                // Debounce period elapsed - update overlays
104                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    /// Apply highlights for the current word
126    #[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        // Clear existing reference highlight overlays
142        overlays.clear_namespace(&ns, marker_list);
143
144        // If no word under cursor, we're done
145        if self.current_word.is_none() {
146            return;
147        }
148
149        // Compute occurrences
150        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        // Create overlays for each occurrence
160        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) // Lower priority than diagnostics
164                .with_theme_key("ui.semantic_highlight_bg");
165
166            overlays.add(overlay);
167        }
168    }
169
170    /// Check if a redraw is needed (debounce timer pending)
171    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    /// Force clear all highlights (e.g., when switching buffers)
183    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    /// Check if currently debouncing
192    pub fn is_debouncing(&self) -> bool {
193        self.word_changed_at.is_some()
194    }
195
196    /// Get the debounce delay
197    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
208/// Get the word at the given position in the buffer
209fn 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    // Check if cursor is on a word character
218    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    // Find word boundaries
233    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        // Middle of "hello"
254        let word = get_word_at_position(&buffer, 2);
255        assert_eq!(word, Some("hello".to_string()));
256
257        // On space - no word
258        let word = get_word_at_position(&buffer, 5);
259        assert_eq!(word, None);
260
261        // Start of "world"
262        let word = get_word_at_position(&buffer, 6);
263        assert_eq!(word, Some("world".to_string()));
264    }
265}