1use tracing::warn;
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct SearchMatch {
11 pub row: usize,
13 pub column: usize,
15 pub value: String,
17 pub highlight_range: (usize, usize),
19}
20
21#[derive(Debug, Clone)]
23pub struct SearchConfig {
24 pub case_sensitive: bool,
26 pub use_regex: bool,
28 pub visible_columns_only: bool,
30 pub wrap_around: bool,
32}
33
34impl Default for SearchConfig {
35 fn default() -> Self {
36 Self {
37 case_sensitive: false,
38 use_regex: false,
39 visible_columns_only: false,
40 wrap_around: true,
41 }
42 }
43}
44
45pub struct SearchManager {
47 pattern: String,
49 matches: Vec<SearchMatch>,
51 current_index: usize,
53 config: SearchConfig,
55 regex: Option<regex::Regex>,
57}
58
59impl SearchManager {
60 pub fn new() -> Self {
62 Self {
63 pattern: String::new(),
64 matches: Vec::new(),
65 current_index: 0,
66 config: SearchConfig::default(),
67 regex: None,
68 }
69 }
70
71 pub fn with_config(config: SearchConfig) -> Self {
73 Self {
74 pattern: String::new(),
75 matches: Vec::new(),
76 current_index: 0,
77 config,
78 regex: None,
79 }
80 }
81
82 pub fn set_config(&mut self, config: SearchConfig) {
84 if !config.use_regex {
86 self.regex = None;
87 }
88 self.config = config;
89 }
90
91 pub fn set_case_sensitive(&mut self, case_sensitive: bool) {
93 self.config.case_sensitive = case_sensitive;
94 if self.config.use_regex && !self.pattern.is_empty() {
96 self.compile_regex();
97 }
98 }
99
100 pub fn search(
103 &mut self,
104 pattern: &str,
105 data: &[Vec<String>],
106 visible_columns: Option<&[usize]>,
107 ) -> usize {
108 self.pattern = pattern.to_string();
109 self.matches.clear();
110 self.current_index = 0;
111
112 if pattern.is_empty() {
113 return 0;
114 }
115
116 if self.config.use_regex {
118 self.compile_regex();
119 if self.regex.is_none() {
120 return 0; }
122 }
123
124 let columns_to_search: Vec<usize> = if self.config.visible_columns_only {
126 visible_columns
127 .map(|cols| cols.to_vec())
128 .unwrap_or_else(|| {
129 if !data.is_empty() {
131 (0..data[0].len()).collect()
132 } else {
133 vec![]
134 }
135 })
136 } else {
137 if !data.is_empty() {
139 (0..data[0].len()).collect()
140 } else {
141 vec![]
142 }
143 };
144
145 for (row_idx, row) in data.iter().enumerate() {
147 for &col_idx in &columns_to_search {
148 if col_idx >= row.len() {
149 continue;
150 }
151
152 let cell_value = &row[col_idx];
153 if let Some(range) = self.matches_pattern(cell_value, pattern) {
154 self.matches.push(SearchMatch {
155 row: row_idx,
156 column: col_idx,
157 value: cell_value.clone(),
158 highlight_range: range,
159 });
160 }
161 }
162 }
163
164 self.matches.len()
165 }
166
167 fn matches_pattern(&self, value: &str, pattern: &str) -> Option<(usize, usize)> {
169 if self.config.use_regex {
170 if let Some(ref regex) = self.regex {
172 if let Some(m) = regex.find(value) {
173 return Some((m.start(), m.end()));
174 }
175 }
176 } else {
177 let search_value = if self.config.case_sensitive {
179 value.to_string()
180 } else {
181 value.to_lowercase()
182 };
183
184 let search_pattern = if self.config.case_sensitive {
185 pattern.to_string()
186 } else {
187 pattern.to_lowercase()
188 };
189
190 if let Some(pos) = search_value.find(&search_pattern) {
191 return Some((pos, pos + pattern.len()));
192 }
193 }
194 None
195 }
196
197 fn compile_regex(&mut self) {
199 let pattern = if self.config.case_sensitive {
200 self.pattern.clone()
201 } else {
202 format!("(?i){}", self.pattern)
203 };
204
205 match regex::Regex::new(&pattern) {
206 Ok(regex) => self.regex = Some(regex),
207 Err(e) => {
208 warn!("Invalid regex pattern: {}", e);
209 self.regex = None;
210 }
211 }
212 }
213
214 pub fn current_match(&self) -> Option<&SearchMatch> {
216 if self.matches.is_empty() {
217 None
218 } else {
219 self.matches.get(self.current_index)
220 }
221 }
222
223 pub fn next_match(&mut self) -> Option<&SearchMatch> {
225 if self.matches.is_empty() {
226 return None;
227 }
228
229 if self.current_index + 1 < self.matches.len() {
230 self.current_index += 1;
231 } else if self.config.wrap_around {
232 self.current_index = 0;
233 }
234
235 self.current_match()
236 }
237
238 pub fn previous_match(&mut self) -> Option<&SearchMatch> {
240 if self.matches.is_empty() {
241 return None;
242 }
243
244 if self.current_index > 0 {
245 self.current_index -= 1;
246 } else if self.config.wrap_around {
247 self.current_index = self.matches.len() - 1;
248 }
249
250 self.current_match()
251 }
252
253 pub fn jump_to_match(&mut self, index: usize) -> Option<&SearchMatch> {
255 if index < self.matches.len() {
256 self.current_index = index;
257 self.current_match()
258 } else {
259 None
260 }
261 }
262
263 pub fn first_match(&self) -> Option<&SearchMatch> {
265 self.matches.first()
266 }
267
268 pub fn all_matches(&self) -> &[SearchMatch] {
270 &self.matches
271 }
272
273 pub fn match_count(&self) -> usize {
275 self.matches.len()
276 }
277
278 pub fn current_match_number(&self) -> usize {
280 if self.matches.is_empty() {
281 0
282 } else {
283 self.current_index + 1
284 }
285 }
286
287 pub fn clear(&mut self) {
289 self.pattern.clear();
290 self.matches.clear();
291 self.current_index = 0;
292 self.regex = None;
293 }
294
295 pub fn has_active_search(&self) -> bool {
297 !self.pattern.is_empty()
298 }
299
300 pub fn pattern(&self) -> &str {
302 &self.pattern
303 }
304
305 pub fn calculate_scroll_offset(
307 &self,
308 match_pos: &SearchMatch,
309 viewport_height: usize,
310 current_offset: usize,
311 ) -> usize {
312 let row = match_pos.row;
313
314 if row < current_offset {
316 row
317 }
318 else if row >= current_offset + viewport_height {
320 row.saturating_sub(viewport_height / 2)
321 }
322 else {
324 current_offset
325 }
326 }
327
328 pub fn find_next_from(&self, current_row: usize, current_col: usize) -> Option<&SearchMatch> {
330 for match_item in &self.matches {
332 if match_item.row > current_row
333 || (match_item.row == current_row && match_item.column > current_col)
334 {
335 return Some(match_item);
336 }
337 }
338
339 if self.config.wrap_around && !self.matches.is_empty() {
341 return self.matches.first();
342 }
343
344 None
345 }
346
347 pub fn find_previous_from(
349 &self,
350 current_row: usize,
351 current_col: usize,
352 ) -> Option<&SearchMatch> {
353 for match_item in self.matches.iter().rev() {
355 if match_item.row < current_row
356 || (match_item.row == current_row && match_item.column < current_col)
357 {
358 return Some(match_item);
359 }
360 }
361
362 if self.config.wrap_around && !self.matches.is_empty() {
364 return self.matches.last();
365 }
366
367 None
368 }
369}
370
371pub struct SearchIterator<'a> {
373 manager: &'a SearchManager,
374 index: usize,
375}
376
377impl<'a> Iterator for SearchIterator<'a> {
378 type Item = &'a SearchMatch;
379
380 fn next(&mut self) -> Option<Self::Item> {
381 if self.index < self.manager.matches.len() {
382 let result = &self.manager.matches[self.index];
383 self.index += 1;
384 Some(result)
385 } else {
386 None
387 }
388 }
389}
390
391impl SearchManager {
392 pub fn iter(&self) -> SearchIterator {
394 SearchIterator {
395 manager: self,
396 index: 0,
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn test_case_insensitive_search() {
407 let mut manager = SearchManager::new();
408 manager.set_case_sensitive(false);
409
410 let data = vec![
411 vec!["Unconfirmed".to_string(), "data1".to_string()],
412 vec!["unconfirmed".to_string(), "data2".to_string()],
413 vec!["UNCONFIRMED".to_string(), "data3".to_string()],
414 vec!["confirmed".to_string(), "data4".to_string()],
415 ];
416
417 let count = manager.search("unconfirmed", &data, None);
418 assert_eq!(count, 3);
419
420 let matches: Vec<_> = manager.iter().collect();
422 assert_eq!(matches.len(), 3);
423 assert_eq!(matches[0].row, 0);
424 assert_eq!(matches[1].row, 1);
425 assert_eq!(matches[2].row, 2);
426 }
427
428 #[test]
429 fn test_case_sensitive_search() {
430 let mut manager = SearchManager::new();
431 manager.set_case_sensitive(true);
432
433 let data = vec![
434 vec!["Unconfirmed".to_string(), "data1".to_string()],
435 vec!["unconfirmed".to_string(), "data2".to_string()],
436 vec!["UNCONFIRMED".to_string(), "data3".to_string()],
437 ];
438
439 let count = manager.search("Unconfirmed", &data, None);
440 assert_eq!(count, 1);
441
442 let first_match = manager.first_match().unwrap();
443 assert_eq!(first_match.row, 0);
444 assert_eq!(first_match.value, "Unconfirmed");
445 }
446
447 #[test]
448 fn test_navigation() {
449 let mut manager = SearchManager::new();
450
451 let data = vec![
452 vec!["apple".to_string(), "banana".to_string()],
453 vec!["apple pie".to_string(), "cherry".to_string()],
454 vec!["orange".to_string(), "apple juice".to_string()],
455 ];
456
457 manager.search("apple", &data, None);
458 assert_eq!(manager.match_count(), 3);
459
460 let first = manager.current_match().unwrap();
462 assert_eq!((first.row, first.column), (0, 0));
463
464 let second = manager.next_match().unwrap();
465 assert_eq!((second.row, second.column), (1, 0));
466
467 let third = manager.next_match().unwrap();
468 assert_eq!((third.row, third.column), (2, 1));
469
470 let wrapped = manager.next_match().unwrap();
472 assert_eq!((wrapped.row, wrapped.column), (0, 0));
473
474 let prev = manager.previous_match().unwrap();
476 assert_eq!((prev.row, prev.column), (2, 1));
477 }
478
479 #[test]
480 fn test_visible_columns_filter() {
481 let mut config = SearchConfig::default();
482 config.visible_columns_only = true;
483 let mut manager = SearchManager::with_config(config);
484
485 let data = vec![
486 vec![
487 "apple".to_string(),
488 "hidden".to_string(),
489 "banana".to_string(),
490 ],
491 vec![
492 "orange".to_string(),
493 "apple".to_string(),
494 "cherry".to_string(),
495 ],
496 ];
497
498 let visible = vec![0, 2];
500 let count = manager.search("apple", &data, Some(&visible));
501
502 assert_eq!(count, 1);
504 let match_item = manager.first_match().unwrap();
505 assert_eq!(match_item.row, 0);
506 assert_eq!(match_item.column, 0);
507 }
508
509 #[test]
510 fn test_scroll_offset_calculation() {
511 let manager = SearchManager::new();
512
513 let match_item = SearchMatch {
514 row: 50,
515 column: 0,
516 value: String::new(),
517 highlight_range: (0, 0),
518 };
519
520 let offset = manager.calculate_scroll_offset(&match_item, 20, 10);
522 assert_eq!(offset, 40); let offset = manager.calculate_scroll_offset(&match_item, 20, 60);
526 assert_eq!(offset, 50);
527
528 let offset = manager.calculate_scroll_offset(&match_item, 20, 45);
530 assert_eq!(offset, 45);
531 }
532
533 #[test]
534 fn test_find_from_position() {
535 let mut manager = SearchManager::new();
536
537 let data = vec![
538 vec!["a".to_string(), "b".to_string(), "match".to_string()],
539 vec!["match".to_string(), "c".to_string(), "d".to_string()],
540 vec!["e".to_string(), "match".to_string(), "f".to_string()],
541 ];
542
543 manager.search("match", &data, None);
544
545 let next = manager.find_next_from(0, 1).unwrap();
547 assert_eq!((next.row, next.column), (0, 2));
548
549 let next = manager.find_next_from(1, 0).unwrap();
551 assert_eq!((next.row, next.column), (2, 1));
552
553 let prev = manager.find_previous_from(2, 0).unwrap();
555 assert_eq!((prev.row, prev.column), (1, 0));
556 }
557}