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}