Skip to main content

romm_cli/tui/
text_search.rs

1//! Shared filter/jump search state and string matching for library list and ROM panes.
2
3use unicode_normalization::UnicodeNormalization;
4
5/// Filter vs jump mode (same semantics for consoles/collections list and games table).
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LibrarySearchMode {
8    Filter,
9    Jump,
10}
11
12/// Typing/filter state for one pane (list or ROMs).
13#[derive(Debug, Clone)]
14pub struct SearchState {
15    pub mode: Option<LibrarySearchMode>,
16    pub query: String,
17    pub normalized_query: String,
18    pub filter_browsing: bool,
19}
20
21impl Default for SearchState {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl SearchState {
28    pub fn new() -> Self {
29        Self {
30            mode: None,
31            query: String::new(),
32            normalized_query: String::new(),
33            filter_browsing: false,
34        }
35    }
36
37    pub fn clear(&mut self) {
38        self.mode = None;
39        self.filter_browsing = false;
40        self.query.clear();
41        self.normalized_query.clear();
42    }
43
44    pub fn enter(&mut self, mode: LibrarySearchMode) {
45        self.mode = Some(mode);
46        self.filter_browsing = false;
47        self.query.clear();
48        self.normalized_query.clear();
49    }
50
51    pub fn add_char(&mut self, c: char) {
52        self.query.push(c);
53        self.normalized_query = normalize_label(&self.query);
54    }
55
56    pub fn delete_char(&mut self) {
57        self.query.pop();
58        self.normalized_query = normalize_label(&self.query);
59    }
60
61    /// True when the ROM/list table should show only rows matching the query.
62    pub fn filter_active(&self) -> bool {
63        (self.mode == Some(LibrarySearchMode::Filter) || self.filter_browsing)
64            && !self.normalized_query.is_empty()
65    }
66
67    /// Enter pressed while in filter typing mode: close bar; optionally enter filter-browse.
68    pub fn commit_filter_bar(&mut self) {
69        if self.mode == Some(LibrarySearchMode::Filter) {
70            self.mode = None;
71            self.filter_browsing = !self.query.trim().is_empty();
72        } else {
73            self.mode = None;
74        }
75    }
76}
77
78/// Strip diacritics and lowercase for substring search.
79pub fn normalize_label(s: &str) -> String {
80    s.nfd()
81        .filter(|c| !unicode_normalization::char::is_combining_mark(*c))
82        .collect::<String>()
83        .to_lowercase()
84}
85
86/// Source indices into `labels` where the normalized label contains `normalized_query`.
87pub fn filter_source_indices(labels: &[String], normalized_query: &str) -> Vec<usize> {
88    if normalized_query.is_empty() {
89        return (0..labels.len()).collect();
90    }
91    labels
92        .iter()
93        .enumerate()
94        .filter(|(_, lab)| normalize_label(lab).contains(normalized_query))
95        .map(|(i, _)| i)
96        .collect()
97}
98
99/// Next matching row index in the full `labels` slice (jump search), wrapping.
100pub fn jump_next_index(
101    labels: &[String],
102    selected_source: usize,
103    normalized_query: &str,
104    next: bool,
105) -> Option<usize> {
106    if normalized_query.is_empty() || labels.is_empty() {
107        return None;
108    }
109    let len = labels.len();
110    let start = if next {
111        (selected_source + 1) % len
112    } else {
113        selected_source
114    };
115    for i in 0..len {
116        let idx = (start + i) % len;
117        if normalize_label(&labels[idx]).contains(normalized_query) {
118            return Some(idx);
119        }
120    }
121    None
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn filter_source_indices_finds_substrings() {
130        let labels = vec![
131            "Alpha".to_string(),
132            "Beta".to_string(),
133            "Alphabet".to_string(),
134        ];
135        let q = normalize_label("alp");
136        let idx = filter_source_indices(&labels, &q);
137        assert_eq!(idx, vec![0, 2]);
138    }
139
140    #[test]
141    fn jump_next_index_wraps() {
142        let labels = vec!["a".into(), "bb".into(), "ab".into()];
143        let q = normalize_label("b");
144        assert_eq!(jump_next_index(&labels, 0, &q, false), Some(1));
145        assert_eq!(jump_next_index(&labels, 1, &q, true), Some(2));
146        assert_eq!(jump_next_index(&labels, 2, &q, true), Some(1));
147    }
148}