thoth_cli/
title_select_popup.rs1use 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}