1use fuzzy_matcher::FuzzyMatcher;
4use fuzzy_matcher::skim::SkimMatcherV2;
5
6#[derive(Debug, Clone)]
8pub struct PickerItem {
9 pub name: String,
11 pub description: Option<String>,
13 pub data: Option<String>,
15}
16
17impl PickerItem {
18 pub fn new(name: impl Into<String>) -> Self {
20 Self {
21 name: name.into(),
22 description: None,
23 data: None,
24 }
25 }
26
27 pub fn with_description(mut self, description: impl Into<String>) -> Self {
29 self.description = Some(description.into());
30 self
31 }
32
33 #[allow(dead_code)]
35 pub fn with_data(mut self, data: impl Into<String>) -> Self {
36 self.data = Some(data.into());
37 self
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct FilteredItem {
44 pub index: usize,
46 pub score: i64,
48 pub positions: Vec<usize>,
50}
51
52pub struct PickerState {
54 items: Vec<PickerItem>,
56 filtered: Vec<FilteredItem>,
58 filter: String,
60 cursor: usize,
62 scroll_offset: usize,
64 visible_height: usize,
66 matcher: SkimMatcherV2,
68}
69
70impl std::fmt::Debug for PickerState {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.debug_struct("PickerState")
73 .field("items", &self.items)
74 .field("filtered", &self.filtered)
75 .field("filter", &self.filter)
76 .field("cursor", &self.cursor)
77 .field("scroll_offset", &self.scroll_offset)
78 .field("visible_height", &self.visible_height)
79 .finish_non_exhaustive()
80 }
81}
82
83impl Clone for PickerState {
84 fn clone(&self) -> Self {
85 Self {
86 items: self.items.clone(),
87 filtered: self.filtered.clone(),
88 filter: self.filter.clone(),
89 cursor: self.cursor,
90 scroll_offset: self.scroll_offset,
91 visible_height: self.visible_height,
92 matcher: SkimMatcherV2::default(),
93 }
94 }
95}
96
97impl PickerState {
98 pub fn new(items: Vec<PickerItem>) -> Self {
100 let filtered: Vec<FilteredItem> = items
101 .iter()
102 .enumerate()
103 .map(|(i, _)| FilteredItem {
104 index: i,
105 score: 0,
106 positions: Vec::new(),
107 })
108 .collect();
109
110 Self {
111 items,
112 filtered,
113 filter: String::new(),
114 cursor: 0,
115 scroll_offset: 0,
116 visible_height: 10,
117 matcher: SkimMatcherV2::default(),
118 }
119 }
120
121 pub fn with_visible_height(mut self, height: usize) -> Self {
123 self.visible_height = height;
124 self
125 }
126
127 pub fn filter(&self) -> &str {
129 &self.filter
130 }
131
132 pub fn selected(&self) -> Option<&PickerItem> {
134 self.filtered.get(self.cursor).map(|f| &self.items[f.index])
135 }
136
137 pub fn visible_items(&self) -> impl Iterator<Item = VisibleItem<'_>> {
139 let start = self.scroll_offset;
140 let end = (self.scroll_offset + self.visible_height).min(self.filtered.len());
141
142 self.filtered[start..end]
143 .iter()
144 .enumerate()
145 .map(move |(i, filtered)| VisibleItem {
146 item: &self.items[filtered.index],
147 is_selected: start + i == self.cursor,
148 positions: &filtered.positions,
149 })
150 }
151
152 pub fn has_more_above(&self) -> bool {
154 self.scroll_offset > 0
155 }
156
157 pub fn has_more_below(&self) -> bool {
159 self.scroll_offset + self.visible_height < self.filtered.len()
160 }
161
162 pub fn filtered_count(&self) -> usize {
164 self.filtered.len()
165 }
166
167 #[allow(dead_code)]
169 pub fn total_count(&self) -> usize {
170 self.items.len()
171 }
172
173 pub fn type_char(&mut self, c: char) {
175 self.filter.push(c);
176 self.apply_filter();
177 }
178
179 pub fn backspace(&mut self) {
181 self.filter.pop();
182 self.apply_filter();
183 }
184
185 #[allow(dead_code)]
187 pub fn clear_filter(&mut self) {
188 self.filter.clear();
189 self.apply_filter();
190 }
191
192 pub fn move_up(&mut self) {
194 if self.cursor > 0 {
195 self.cursor -= 1;
196 self.ensure_cursor_visible();
197 }
198 }
199
200 pub fn move_down(&mut self) {
202 if self.cursor + 1 < self.filtered.len() {
203 self.cursor += 1;
204 self.ensure_cursor_visible();
205 }
206 }
207
208 fn apply_filter(&mut self) {
210 if self.filter.is_empty() {
211 self.filtered = self
213 .items
214 .iter()
215 .enumerate()
216 .map(|(i, _)| FilteredItem {
217 index: i,
218 score: 0,
219 positions: Vec::new(),
220 })
221 .collect();
222 } else {
223 self.filtered = self
225 .items
226 .iter()
227 .enumerate()
228 .filter_map(|(i, item)| {
229 let name_match = self.matcher.fuzzy_indices(&item.name, &self.filter);
231 let desc_match = item
232 .description
233 .as_ref()
234 .and_then(|d| self.matcher.fuzzy_match(d, &self.filter));
235
236 match (name_match, desc_match) {
238 (Some((name_score, positions)), Some(desc_score)) => Some(FilteredItem {
239 index: i,
240 score: name_score.max(desc_score),
241 positions,
242 }),
243 (Some((score, positions)), None) => Some(FilteredItem {
244 index: i,
245 score,
246 positions,
247 }),
248 (None, Some(score)) => Some(FilteredItem {
249 index: i,
250 score,
251 positions: Vec::new(),
252 }),
253 (None, None) => None,
254 }
255 })
256 .collect();
257
258 self.filtered.sort_by(|a, b| b.score.cmp(&a.score));
260 }
261
262 self.cursor = 0;
264 self.scroll_offset = 0;
265 }
266
267 fn ensure_cursor_visible(&mut self) {
269 if self.cursor < self.scroll_offset {
270 self.scroll_offset = self.cursor;
271 } else if self.cursor >= self.scroll_offset + self.visible_height {
272 self.scroll_offset = self.cursor.saturating_sub(self.visible_height - 1);
273 }
274 }
275}
276
277#[derive(Debug)]
279pub struct VisibleItem<'a> {
280 pub item: &'a PickerItem,
282 pub is_selected: bool,
284 pub positions: &'a [usize],
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291
292 #[test]
293 fn test_picker_basic() {
294 let items = vec![
295 PickerItem::new("node").with_description("Node.js runtime"),
296 PickerItem::new("python").with_description("Python interpreter"),
297 PickerItem::new("ruby").with_description("Ruby interpreter"),
298 ];
299
300 let picker = PickerState::new(items);
301 assert_eq!(picker.filtered_count(), 3);
302 assert_eq!(picker.selected().unwrap().name, "node");
303 }
304
305 #[test]
306 fn test_picker_filter() {
307 let items = vec![
308 PickerItem::new("node"),
309 PickerItem::new("python"),
310 PickerItem::new("ruby"),
311 PickerItem::new("nodenv"),
312 ];
313
314 let mut picker = PickerState::new(items);
315 picker.type_char('n');
316 picker.type_char('o');
317 picker.type_char('d');
318
319 assert_eq!(picker.filtered_count(), 2);
321
322 let selected = picker.selected().unwrap();
324 assert!(selected.name == "node" || selected.name == "nodenv");
325 }
326
327 #[test]
328 fn test_picker_navigation() {
329 let items = vec![
330 PickerItem::new("a"),
331 PickerItem::new("b"),
332 PickerItem::new("c"),
333 ];
334
335 let mut picker = PickerState::new(items);
336 assert_eq!(picker.selected().unwrap().name, "a");
337
338 picker.move_down();
339 assert_eq!(picker.selected().unwrap().name, "b");
340
341 picker.move_down();
342 assert_eq!(picker.selected().unwrap().name, "c");
343
344 picker.move_down(); assert_eq!(picker.selected().unwrap().name, "c");
346
347 picker.move_up();
348 assert_eq!(picker.selected().unwrap().name, "b");
349 }
350
351 #[test]
352 fn test_picker_backspace() {
353 let items = vec![PickerItem::new("node"), PickerItem::new("python")];
354
355 let mut picker = PickerState::new(items);
356 picker.type_char('p');
357 picker.type_char('y');
358 assert_eq!(picker.filtered_count(), 1);
359
360 picker.backspace();
361 picker.backspace();
362 assert_eq!(picker.filtered_count(), 2);
363 }
364}