Skip to main content

mise_interactive_config/
picker.rs

1//! Picker: Fuzzy-searchable list picker UI component
2
3use fuzzy_matcher::FuzzyMatcher;
4use fuzzy_matcher::skim::SkimMatcherV2;
5
6/// An item that can be displayed in the picker
7#[derive(Debug, Clone)]
8pub struct PickerItem {
9    /// Display name (used for matching and display)
10    pub name: String,
11    /// Optional description shown next to the name
12    pub description: Option<String>,
13    /// Optional data payload (e.g., tool backend info)
14    pub data: Option<String>,
15}
16
17impl PickerItem {
18    /// Create a new picker item
19    pub fn new(name: impl Into<String>) -> Self {
20        Self {
21            name: name.into(),
22            description: None,
23            data: None,
24        }
25    }
26
27    /// Add a description
28    pub fn with_description(mut self, description: impl Into<String>) -> Self {
29        self.description = Some(description.into());
30        self
31    }
32
33    /// Add data payload
34    #[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/// Filtered item with match score for sorting
42#[derive(Debug, Clone)]
43pub struct FilteredItem {
44    /// Index into the original items list
45    pub index: usize,
46    /// Match score (higher is better)
47    pub score: i64,
48    /// Matched positions in the name (for highlighting)
49    pub positions: Vec<usize>,
50}
51
52/// State for the fuzzy picker
53pub struct PickerState {
54    /// All available items
55    items: Vec<PickerItem>,
56    /// Filtered items after applying search filter
57    filtered: Vec<FilteredItem>,
58    /// Current filter text
59    filter: String,
60    /// Selected index in the filtered list
61    cursor: usize,
62    /// Scroll offset for the visible window
63    scroll_offset: usize,
64    /// Height of visible area (number of items)
65    visible_height: usize,
66    /// Fuzzy matcher instance (created fresh, not stored for Clone/Debug)
67    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    /// Create a new picker with the given items
99    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    /// Set the visible height
122    pub fn with_visible_height(mut self, height: usize) -> Self {
123        self.visible_height = height;
124        self
125    }
126
127    /// Get the current filter text
128    pub fn filter(&self) -> &str {
129        &self.filter
130    }
131
132    /// Get the currently selected item, if any
133    pub fn selected(&self) -> Option<&PickerItem> {
134        self.filtered.get(self.cursor).map(|f| &self.items[f.index])
135    }
136
137    /// Get visible items with their display info
138    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    /// Check if there are more items above the visible area
153    pub fn has_more_above(&self) -> bool {
154        self.scroll_offset > 0
155    }
156
157    /// Check if there are more items below the visible area
158    pub fn has_more_below(&self) -> bool {
159        self.scroll_offset + self.visible_height < self.filtered.len()
160    }
161
162    /// Get the total number of filtered items
163    pub fn filtered_count(&self) -> usize {
164        self.filtered.len()
165    }
166
167    /// Get the total number of items
168    #[allow(dead_code)]
169    pub fn total_count(&self) -> usize {
170        self.items.len()
171    }
172
173    /// Add a character to the filter
174    pub fn type_char(&mut self, c: char) {
175        self.filter.push(c);
176        self.apply_filter();
177    }
178
179    /// Remove the last character from the filter
180    pub fn backspace(&mut self) {
181        self.filter.pop();
182        self.apply_filter();
183    }
184
185    /// Clear the filter
186    #[allow(dead_code)]
187    pub fn clear_filter(&mut self) {
188        self.filter.clear();
189        self.apply_filter();
190    }
191
192    /// Move cursor up
193    pub fn move_up(&mut self) {
194        if self.cursor > 0 {
195            self.cursor -= 1;
196            self.ensure_cursor_visible();
197        }
198    }
199
200    /// Move cursor down
201    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    /// Apply the current filter to the items
209    fn apply_filter(&mut self) {
210        if self.filter.is_empty() {
211            // No filter - show all items in original order
212            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            // Apply fuzzy matching
224            self.filtered = self
225                .items
226                .iter()
227                .enumerate()
228                .filter_map(|(i, item)| {
229                    // Match against name and description
230                    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                    // Take the best score
237                    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            // Sort by score (highest first)
259            self.filtered.sort_by(|a, b| b.score.cmp(&a.score));
260        }
261
262        // Reset cursor to start
263        self.cursor = 0;
264        self.scroll_offset = 0;
265    }
266
267    /// Ensure the cursor is visible in the viewport
268    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/// A visible item in the picker with display metadata
278#[derive(Debug)]
279pub struct VisibleItem<'a> {
280    /// The item to display
281    pub item: &'a PickerItem,
282    /// Whether this item is currently selected
283    pub is_selected: bool,
284    /// Character positions to highlight (from fuzzy match)
285    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        // Should match "node" and "nodenv"
320        assert_eq!(picker.filtered_count(), 2);
321
322        // "node" should rank higher (exact prefix match)
323        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(); // Should stay at end
345        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}