Skip to main content

envision/component/text_area/
search.rs

1/// Search functionality for TextAreaState.
2///
3/// Provides text search with match highlighting and navigation.
4/// Extracted to a submodule to keep the main module under the
5/// 1000-line limit.
6use super::TextAreaState;
7
8impl TextAreaState {
9    /// Returns the current search query, if any.
10    ///
11    /// # Example
12    ///
13    /// ```rust
14    /// use envision::component::{TextArea, TextAreaMessage, TextAreaState, Component};
15    ///
16    /// let mut state = TextAreaState::new().with_value("hello world");
17    /// assert_eq!(state.search_query(), None);
18    ///
19    /// TextArea::update(&mut state, TextAreaMessage::SetSearchQuery("hello".into()));
20    /// assert_eq!(state.search_query(), Some("hello"));
21    /// ```
22    pub fn search_query(&self) -> Option<&str> {
23        self.search_query.as_deref()
24    }
25
26    /// Returns the list of search matches as (line, byte_col) pairs.
27    ///
28    /// # Example
29    ///
30    /// ```rust
31    /// use envision::component::{TextArea, TextAreaMessage, TextAreaState, Component};
32    ///
33    /// let mut state = TextAreaState::new().with_value("foo bar foo");
34    /// TextArea::update(&mut state, TextAreaMessage::SetSearchQuery("foo".into()));
35    /// assert_eq!(state.search_matches().len(), 2);
36    /// ```
37    pub fn search_matches(&self) -> &[(usize, usize)] {
38        &self.search_matches
39    }
40
41    /// Returns the index of the current match within the match list.
42    ///
43    /// # Example
44    ///
45    /// ```rust
46    /// use envision::component::{TextArea, TextAreaMessage, TextAreaState, Component};
47    ///
48    /// let mut state = TextAreaState::new().with_value("aaa");
49    /// TextArea::update(&mut state, TextAreaMessage::SetSearchQuery("a".into()));
50    /// assert_eq!(state.current_match_index(), 0);
51    /// ```
52    pub fn current_match_index(&self) -> usize {
53        self.current_match
54    }
55
56    /// Returns the current match as (line, byte_col), if any.
57    ///
58    /// # Example
59    ///
60    /// ```rust
61    /// use envision::component::{TextArea, TextAreaMessage, TextAreaState, Component};
62    ///
63    /// let mut state = TextAreaState::new().with_value("hello");
64    /// TextArea::update(&mut state, TextAreaMessage::SetSearchQuery("hello".into()));
65    /// assert_eq!(state.current_match_position(), Some((0, 0)));
66    /// ```
67    pub fn current_match_position(&self) -> Option<(usize, usize)> {
68        self.search_matches.get(self.current_match).copied()
69    }
70
71    /// Returns true if search mode is active.
72    ///
73    /// # Example
74    ///
75    /// ```rust
76    /// use envision::component::{TextArea, TextAreaMessage, TextAreaState, Component};
77    ///
78    /// let mut state = TextAreaState::new();
79    /// assert!(!state.is_searching());
80    ///
81    /// TextArea::update(&mut state, TextAreaMessage::StartSearch);
82    /// assert!(state.is_searching());
83    /// ```
84    pub fn is_searching(&self) -> bool {
85        self.search_query.is_some()
86    }
87
88    /// Starts search mode with an empty query.
89    pub(super) fn start_search(&mut self) {
90        if self.search_query.is_none() {
91            self.search_query = Some(String::new());
92            self.search_matches.clear();
93            self.current_match = 0;
94        }
95    }
96
97    /// Sets the search query and recomputes matches.
98    pub(super) fn set_search_query(&mut self, query: String) {
99        self.search_query = Some(query);
100        self.recompute_matches();
101        // Jump to first match at or after current cursor position
102        self.jump_to_nearest_match_forward();
103    }
104
105    /// Advances to the next match, wrapping around.
106    pub(super) fn next_match(&mut self) {
107        if self.search_matches.is_empty() {
108            return;
109        }
110        self.current_match = (self.current_match + 1) % self.search_matches.len();
111        self.jump_cursor_to_current_match();
112    }
113
114    /// Goes to the previous match, wrapping around.
115    pub(super) fn prev_match(&mut self) {
116        if self.search_matches.is_empty() {
117            return;
118        }
119        if self.current_match == 0 {
120            self.current_match = self.search_matches.len() - 1;
121        } else {
122            self.current_match -= 1;
123        }
124        self.jump_cursor_to_current_match();
125    }
126
127    /// Clears search state entirely.
128    pub(super) fn clear_search(&mut self) {
129        self.search_query = None;
130        self.search_matches.clear();
131        self.current_match = 0;
132    }
133
134    /// Recomputes matches for the current query against the lines.
135    pub(super) fn recompute_matches(&mut self) {
136        self.search_matches.clear();
137        self.current_match = 0;
138
139        let query = match &self.search_query {
140            Some(q) if !q.is_empty() => q.clone(),
141            _ => return,
142        };
143
144        for (line_idx, line) in self.lines.iter().enumerate() {
145            let mut start = 0;
146            while let Some(pos) = line[start..].find(&query) {
147                let byte_col = start + pos;
148                self.search_matches.push((line_idx, byte_col));
149                // Advance past this match to find overlapping/subsequent matches
150                start = byte_col + 1;
151                if start >= line.len() {
152                    break;
153                }
154            }
155        }
156    }
157
158    /// Jumps the cursor to the current match position.
159    fn jump_cursor_to_current_match(&mut self) {
160        if let Some(&(row, col)) = self.search_matches.get(self.current_match) {
161            self.cursor_row = row;
162            self.cursor_col = col;
163            self.clear_selection();
164        }
165    }
166
167    /// Finds the nearest match at or after the cursor position and sets it as current.
168    fn jump_to_nearest_match_forward(&mut self) {
169        if self.search_matches.is_empty() {
170            return;
171        }
172
173        let cursor = (self.cursor_row, self.cursor_col);
174        for (i, &match_pos) in self.search_matches.iter().enumerate() {
175            if match_pos >= cursor {
176                self.current_match = i;
177                self.jump_cursor_to_current_match();
178                return;
179            }
180        }
181
182        // Wrap to first match if no match found after cursor
183        self.current_match = 0;
184        self.jump_cursor_to_current_match();
185    }
186}