romm_cli/tui/
text_search.rs1use unicode_normalization::UnicodeNormalization;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LibrarySearchMode {
8 Filter,
9 Jump,
10}
11
12#[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 pub fn filter_active(&self) -> bool {
63 (self.mode == Some(LibrarySearchMode::Filter) || self.filter_browsing)
64 && !self.normalized_query.is_empty()
65 }
66
67 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
78pub 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
86pub 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
99pub 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}