Skip to main content

alopex_cli/tui/
search.rs

1//! Incremental search state for TUI.
2
3use regex::Regex;
4
5use crate::error::{CliError, Result};
6
7use super::table::{format_value, TableView};
8
9/// Search state for TUI table.
10#[derive(Default)]
11pub struct SearchState {
12    active: bool,
13    query: String,
14    regex: Option<Regex>,
15    matches: Vec<(usize, usize)>,
16    current: Option<usize>,
17}
18
19impl SearchState {
20    pub fn is_active(&self) -> bool {
21        self.active
22    }
23
24    pub fn has_query(&self) -> bool {
25        !self.query.is_empty()
26    }
27
28    pub fn query(&self) -> &str {
29        &self.query
30    }
31
32    pub fn activate(&mut self) {
33        self.active = true;
34    }
35
36    pub fn deactivate(&mut self) {
37        self.active = false;
38    }
39
40    pub fn cancel(&mut self) {
41        self.active = false;
42        self.query.clear();
43        self.regex = None;
44        self.matches.clear();
45        self.current = None;
46    }
47
48    pub fn push_char(&mut self, ch: char, table: &TableView) -> Result<()> {
49        self.query.push(ch);
50        self.recompute(table)?;
51        Ok(())
52    }
53
54    pub fn backspace(&mut self, table: &TableView) -> Result<()> {
55        self.query.pop();
56        self.recompute(table)?;
57        Ok(())
58    }
59
60    pub fn next_match(&mut self, table: &TableView) -> Result<Option<usize>> {
61        if self.matches.is_empty() {
62            self.recompute(table)?;
63        }
64        if self.matches.is_empty() {
65            return Ok(None);
66        }
67        let next = match self.current {
68            None => 0,
69            Some(idx) => (idx + 1) % self.matches.len(),
70        };
71        self.current = Some(next);
72        Ok(self.matches.get(next).map(|(row, _)| *row))
73    }
74
75    pub fn prev_match(&mut self, table: &TableView) -> Result<Option<usize>> {
76        if self.matches.is_empty() {
77            self.recompute(table)?;
78        }
79        if self.matches.is_empty() {
80            return Ok(None);
81        }
82        let prev = match self.current {
83            None => 0,
84            Some(idx) => idx.saturating_sub(1),
85        };
86        self.current = Some(prev);
87        Ok(self.matches.get(prev).map(|(row, _)| *row))
88    }
89
90    pub fn current_match(&self) -> Option<usize> {
91        self.current
92            .and_then(|idx| self.matches.get(idx).map(|(row, _)| *row))
93    }
94
95    pub fn matches_cell(&self, row: usize, col: usize, text: &str) -> bool {
96        self.regex
97            .as_ref()
98            .map(|regex| regex.is_match(text) && self.matches.contains(&(row, col)))
99            .unwrap_or(false)
100    }
101
102    pub fn recompute(&mut self, table: &TableView) -> Result<()> {
103        self.matches.clear();
104        self.current = None;
105        if self.query.is_empty() {
106            self.regex = None;
107            return Ok(());
108        }
109
110        let regex = regex::RegexBuilder::new(&self.query)
111            .case_insensitive(true)
112            .build()
113            .map_err(|err| CliError::InvalidArgument(err.to_string()))?;
114
115        for (row_idx, row) in table.rows().iter().enumerate() {
116            for (col_idx, value) in row.columns.iter().enumerate() {
117                let text = format_value(value);
118                if regex.is_match(&text) {
119                    self.matches.push((row_idx, col_idx));
120                }
121            }
122        }
123
124        if !self.matches.is_empty() {
125            self.current = Some(0);
126        }
127        self.regex = Some(regex);
128        Ok(())
129    }
130}