thoth_cli/
title_select_popup.rs

1use fuzzy_matcher::skim::SkimMatcherV2;
2use fuzzy_matcher::FuzzyMatcher;
3
4pub struct TitleSelectPopup {
5    pub titles: Vec<String>,
6    pub filtered_titles: Vec<TitleMatch>,
7    pub selected_index: usize,
8    pub visible: bool,
9    pub scroll_offset: usize,
10    pub search_query: String,
11}
12
13pub struct TitleMatch {
14    pub title: String,
15    pub index: usize,
16    pub score: i64,
17}
18
19impl TitleMatch {
20    pub fn new(title: String, index: usize, score: i64) -> Self {
21        Self {
22            title,
23            index,
24            score,
25        }
26    }
27}
28
29impl Default for TitleSelectPopup {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl TitleSelectPopup {
36    pub fn new() -> Self {
37        TitleSelectPopup {
38            titles: Vec::new(),
39            filtered_titles: Vec::new(),
40            selected_index: 0,
41            visible: false,
42            scroll_offset: 0,
43            search_query: String::new(),
44        }
45    }
46
47    pub fn set_titles(&mut self, titles: Vec<String>) {
48        self.titles = titles;
49        self.filtered_titles = self
50            .titles
51            .iter()
52            .enumerate()
53            .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0))
54            .collect();
55    }
56
57    pub fn reset_filtered_titles(&mut self) {
58        self.filtered_titles = self
59            .titles
60            .iter()
61            .enumerate()
62            .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0))
63            .collect();
64    }
65
66    pub fn move_selection_up(&mut self, visible_items: usize) {
67        if self.filtered_titles.is_empty() {
68            return;
69        }
70
71        if self.selected_index > 0 {
72            self.selected_index -= 1;
73        } else {
74            self.selected_index = self.filtered_titles.len() - 1;
75        }
76
77        if self.selected_index <= self.scroll_offset {
78            self.scroll_offset = self.selected_index;
79        }
80        if self.selected_index == self.filtered_titles.len() - 1 {
81            self.scroll_offset = self.filtered_titles.len().saturating_sub(visible_items);
82        }
83    }
84
85    pub fn move_selection_down(&mut self, visible_items: usize) {
86        if self.filtered_titles.is_empty() {
87            return;
88        }
89
90        if self.selected_index < self.filtered_titles.len() - 1 {
91            self.selected_index += 1;
92        } else {
93            self.selected_index = 0;
94            self.scroll_offset = 0;
95        }
96
97        let max_scroll = self.filtered_titles.len().saturating_sub(visible_items);
98        if self.selected_index >= self.scroll_offset + visible_items {
99            self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items);
100            if self.scroll_offset > max_scroll {
101                self.scroll_offset = max_scroll;
102            }
103        }
104    }
105
106    pub fn update_search(&mut self) {
107        let matcher = SkimMatcherV2::default();
108
109        let mut matched_titles: Vec<TitleMatch> = self
110            .titles
111            .iter()
112            .enumerate()
113            .filter_map(|(idx, title)| {
114                matcher
115                    .fuzzy_match(title, &self.search_query)
116                    .map(|score| TitleMatch::new(title.clone(), idx, score))
117            })
118            .collect();
119
120        matched_titles.sort_by(|a, b| b.score.cmp(&a.score));
121
122        self.filtered_titles = matched_titles;
123
124        if !self.filtered_titles.is_empty() {
125            self.selected_index = 0;
126            self.scroll_offset = 0;
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_new_title_select_popup() {
137        let popup = TitleSelectPopup::new();
138        assert!(popup.titles.is_empty());
139        assert_eq!(popup.selected_index, 0);
140        assert!(!popup.visible);
141    }
142
143    #[test]
144    fn test_title_select_popup_add_titles() {
145        let mut popup = TitleSelectPopup::new();
146        let titles = vec!["Title1".to_string(), "Title2".to_string()];
147        popup.set_titles(titles);
148        assert_eq!(popup.titles.len(), 2);
149        assert_eq!(popup.titles[0], "Title1");
150        assert_eq!(popup.titles[1], "Title2");
151        assert_eq!(popup.filtered_titles.len(), 2);
152    }
153
154    #[test]
155    fn test_wrap_around_selection() {
156        let mut popup = TitleSelectPopup::new();
157        popup.set_titles(vec!["1".to_string(), "2".to_string(), "3".to_string()]);
158
159        popup.selected_index = 0;
160        popup.move_selection_up(2);
161        assert_eq!(popup.selected_index, 2);
162        assert_eq!(popup.scroll_offset, 1);
163
164        popup.selected_index = 2;
165        popup.move_selection_down(2);
166        assert_eq!(popup.selected_index, 0);
167        assert_eq!(popup.scroll_offset, 0);
168    }
169
170    #[test]
171    fn test_search_filtering() {
172        let mut popup = TitleSelectPopup::new();
173        popup.set_titles(vec![
174            "Apple".to_string(),
175            "Banana".to_string(),
176            "Apricot".to_string(),
177        ]);
178
179        popup.search_query = "ap".to_string();
180        popup.update_search();
181        assert_eq!(popup.filtered_titles.len(), 2);
182        assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apple"));
183        assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apricot"));
184
185        assert!(popup.filtered_titles[0].score >= popup.filtered_titles[1].score);
186    }
187}