Skip to main content

gitu/
picker.rs

1use fuzzy_matcher::FuzzyMatcher;
2use fuzzy_matcher::skim::SkimMatcherV2;
3use std::borrow::Cow;
4
5use crate::item_data::Ref;
6use crate::item_data::Rev;
7use crate::text_input::TextInput;
8
9/// Data that can be selected in a picker
10#[derive(Debug, Clone, PartialEq)]
11pub enum PickerData {
12    /// A selected item value
13    Item(String),
14    /// Custom user input (literal text from input field)
15    CustomInput(String),
16}
17
18impl PickerData {
19    pub fn display(&self) -> &str {
20        match self {
21            PickerData::Item(s) | PickerData::CustomInput(s) => s,
22        }
23    }
24}
25
26/// What a picker whose items can be given keys ended with.
27#[derive(Debug, Clone, PartialEq)]
28pub(crate) enum Picked {
29    Item(PickerData),
30    /// The item was given this key, rather than picked.
31    KeySet(char, PickerData),
32}
33
34/// An item in the picker list
35#[derive(Debug, Clone, PartialEq)]
36pub struct PickerItem {
37    /// The text to display and match against
38    pub display: Cow<'static, str>,
39    /// Associated data
40    pub data: PickerData,
41    /// Whether moving through a picker shown as one row stops here. The list
42    /// always does.
43    pub in_one_row: bool,
44}
45
46impl PickerItem {
47    pub fn new(display: impl Into<Cow<'static, str>>, data: PickerData) -> Self {
48        Self {
49            display: display.into(),
50            data,
51            in_one_row: true,
52        }
53    }
54
55    /// An item that moving through a picker shown as one row passes over.
56    pub(crate) fn only_in_list(mut self) -> Self {
57        self.in_one_row = false;
58        self
59    }
60}
61
62/// Result of a fuzzy match with score
63#[derive(Debug, Clone)]
64struct MatchResult {
65    index: usize,
66    score: i64,
67}
68
69/// Picker status
70#[derive(Debug, Clone, PartialEq)]
71pub enum PickerStatus {
72    /// Picker is active
73    Active,
74    /// User selected an item
75    Done,
76    /// User cancelled
77    Cancelled,
78    /// User gave the selected item this key
79    KeySet(char),
80}
81
82/// State of the picker component
83pub struct PickerState {
84    /// All available items (excluding custom input)
85    items: Vec<PickerItem>,
86    /// Filtered and sorted indices based on current pattern
87    filtered_indices: Vec<usize>,
88    /// Current cursor position in filtered results
89    cursor: usize,
90    /// Current input pattern
91    pub(crate) input_state: TextInput,
92    /// Fuzzy matcher
93    matcher: SkimMatcherV2,
94    /// Prompt text to display
95    pub prompt_text: Cow<'static, str>,
96    /// Current status
97    status: PickerStatus,
98    /// Allow user to input custom value not in the list
99    allow_custom_input: bool,
100    /// Custom input item (separate from items list)
101    custom_input_item: Option<PickerItem>,
102    /// Whether a letter gives the selected item a key, rather than filtering.
103    assigns_keys: bool,
104    /// For a picker that can be shown as one row, whether it is: the selected
105    /// item alone, with letters not typed. `None` for a picker that is always a
106    /// list.
107    one_row: Option<bool>,
108    /// For a picker shown in columns, the columns.
109    grid: Option<Grid>,
110}
111
112/// A picker's items laid out in columns side by side, each under a heading.
113pub(crate) struct Grid {
114    pub(crate) headings: Vec<&'static str>,
115    /// The column of each item, by its index among the items.
116    columns: Vec<usize>,
117}
118
119impl Grid {
120    pub(crate) fn column_of(&self, item_i: usize) -> usize {
121        self.columns[item_i]
122    }
123}
124
125pub(crate) struct PickerParams<'a> {
126    pub(crate) prompt: Cow<'static, str>,
127    pub(crate) refs: &'a [Ref],
128    pub(crate) exclude_ref: Option<Ref>,
129    pub(crate) default: Option<Rev>,
130    pub(crate) allow_custom_input: bool,
131}
132
133impl PickerState {
134    /// Create a new picker with items
135    pub fn new(
136        prompt: impl Into<Cow<'static, str>>,
137        items: Vec<PickerItem>,
138        allow_custom_input: bool,
139    ) -> Self {
140        let mut state = Self {
141            items: items.clone(),
142            filtered_indices: Vec::new(),
143            cursor: 0,
144            input_state: TextInput::default(),
145            matcher: SkimMatcherV2::default(),
146            prompt_text: prompt.into(),
147            status: PickerStatus::Active,
148            allow_custom_input,
149            custom_input_item: None,
150            assigns_keys: false,
151            one_row: None,
152            grid: None,
153        };
154        state.update_filter();
155        state
156    }
157
158    /// Make a letter give the selected item a key, rather than filter: the list
159    /// is moved through, not searched.
160    pub(crate) fn assigning_keys(mut self) -> Self {
161        self.assigns_keys = true;
162        self
163    }
164
165    /// Show the picker as one row, the selected item alone, until it is
166    /// switched to a list by [`Self::toggle_one_row`].
167    pub(crate) fn one_row(mut self) -> Self {
168        self.one_row = Some(true);
169        self
170    }
171
172    /// Show the items in columns side by side, each under its heading in
173    /// `headings`: the column of each item is its entry in `columns`. Up and
174    /// down then move within a column, and left and right between columns.
175    pub(crate) fn in_columns(mut self, headings: Vec<&'static str>, columns: Vec<usize>) -> Self {
176        self.grid = Some(Grid { headings, columns });
177        self
178    }
179
180    pub(crate) fn grid(&self) -> Option<&Grid> {
181        self.grid.as_ref()
182    }
183
184    /// The positions in the filtered list of the items in column `column`.
185    pub(crate) fn column_positions(&self, column: usize) -> Vec<usize> {
186        let Some(grid) = &self.grid else {
187            return vec![];
188        };
189        (0..self.filtered_indices.len())
190            .filter(|&position| grid.column_of(self.filtered_indices[position]) == column)
191            .collect()
192    }
193
194    /// The column of the selected item, in a grid.
195    pub(crate) fn cursor_column(&self) -> Option<usize> {
196        let grid = self.grid.as_ref()?;
197        let item_i = *self.filtered_indices.get(self.cursor)?;
198        Some(grid.column_of(item_i))
199    }
200
201    /// In a grid, move to the column `step` columns along that has items, to
202    /// the row the cursor is on, or the last row of a shorter column. Past the
203    /// first or last such column, the cursor stays where it is.
204    pub(crate) fn move_column(&mut self, step: isize) {
205        let (Some(grid), Some(column)) = (&self.grid, self.cursor_column()) else {
206            return;
207        };
208        let row = self
209            .column_positions(column)
210            .iter()
211            .position(|&position| position == self.cursor)
212            .unwrap_or(0);
213        let mut target = column;
214        loop {
215            let Some(next) = target
216                .checked_add_signed(step)
217                .filter(|&next| next < grid.headings.len())
218            else {
219                return;
220            };
221            target = next;
222            let positions = self.column_positions(target);
223            if let Some(&position) = positions.get(row).or(positions.last()) {
224                self.cursor = position;
225                return;
226            }
227        }
228    }
229
230    pub(crate) fn is_one_row(&self) -> bool {
231        self.one_row == Some(true)
232    }
233
234    /// Switch a picker made with [`Self::one_row`] between one row and a list.
235    /// The search typed in the list stays, so one row moves through what it
236    /// matched.
237    pub(crate) fn toggle_one_row(&mut self) {
238        if let Some(one_row) = &mut self.one_row {
239            *one_row = !*one_row;
240        }
241    }
242
243    pub(crate) fn with_cursor(mut self, cursor: usize) -> Self {
244        if cursor < self.filtered_indices.len() {
245            self.cursor = cursor;
246        }
247        self
248    }
249
250    pub(crate) fn assigns_keys(&self) -> bool {
251        self.assigns_keys
252    }
253
254    pub(crate) fn set_key(&mut self, key: char) {
255        self.status = PickerStatus::KeySet(key);
256    }
257
258    /// Create a picker showing only local branches by shorthand name.
259    /// The default branch (if any) is listed first. The exclude_ref is omitted.
260    pub(crate) fn with_branches(params: PickerParams) -> Self {
261        let default_name = params.default.as_ref().map(|r| r.shorthand().to_string());
262        let exclude_name = params
263            .exclude_ref
264            .as_ref()
265            .map(|r| r.shorthand().to_string());
266
267        let items = default_name
268            .iter()
269            .map(|name| PickerItem::new(name.clone(), PickerData::Item(name.clone())))
270            .chain(
271                params
272                    .refs
273                    .iter()
274                    .filter(|r| matches!(r, Ref::Head(_)))
275                    .filter(|r| exclude_name.as_deref().is_none_or(|e| e != r.shorthand()))
276                    .filter(|r| default_name.as_deref().is_none_or(|d| d != r.shorthand()))
277                    .map(|r| {
278                        let name = r.shorthand().to_string();
279                        PickerItem::new(name.clone(), PickerData::Item(name))
280                    }),
281            )
282            .collect();
283
284        Self::new(params.prompt, items, params.allow_custom_input)
285    }
286
287    /// Create a picker from refs. Tags are prefixed with "tag: " in display.
288    /// The default ref (if any) is listed first. The exclude_ref is omitted.
289    pub(crate) fn with_refs(params: PickerParams) -> Self {
290        let ref_to_item = |r: &Ref| {
291            let (display, refname) = match r {
292                Ref::Tag(name) => (format!("tag: {}", name), r.to_full_refname()),
293                _ => (r.shorthand().to_string(), r.shorthand().to_string()),
294            };
295            PickerItem::new(display, PickerData::Item(refname))
296        };
297
298        let default_item = params.default.as_ref().map(|default| match default {
299            Rev::Ref(r) => ref_to_item(r),
300            Rev::Commit(c) => PickerItem::new(c.clone(), PickerData::Item(c.clone())),
301        });
302
303        let items = default_item
304            .into_iter()
305            .chain(
306                params
307                    .refs
308                    .iter()
309                    .filter(|r| {
310                        params.exclude_ref.as_ref().is_none_or(|e| e != *r)
311                            && params.default.as_ref().is_none_or(|d| match d {
312                                Rev::Ref(d) => d != *r,
313                                _ => true,
314                            })
315                    })
316                    .map(ref_to_item),
317            )
318            .collect();
319
320        Self::new(params.prompt, items, params.allow_custom_input)
321    }
322
323    /// Get current input pattern
324    pub fn pattern(&self) -> &str {
325        &self.input_state.value
326    }
327
328    /// Update the filter based on current input pattern
329    pub fn update_filter(&mut self) {
330        let pattern = self.pattern().to_string();
331
332        if pattern.is_empty() {
333            // Show all items when no pattern
334            self.filtered_indices = (0..self.items.len()).collect();
335            self.custom_input_item = None;
336        } else {
337            // Fuzzy match and sort by score
338            let mut matches: Vec<MatchResult> = self
339                .items
340                .iter()
341                .enumerate()
342                .filter_map(|(i, item)| {
343                    self.matcher
344                        .fuzzy_match(&item.display, &pattern)
345                        .map(|score| MatchResult { index: i, score })
346                })
347                .collect();
348
349            // Sort by score (higher is better)
350            matches.sort_by_key(|b| std::cmp::Reverse(b.score));
351
352            self.filtered_indices = matches.into_iter().map(|m| m.index).collect();
353
354            // Create custom input item if enabled
355            if self.allow_custom_input {
356                self.custom_input_item = Some(PickerItem::new(
357                    pattern.clone(),
358                    PickerData::CustomInput(pattern),
359                ));
360            } else {
361                self.custom_input_item = None;
362            }
363        }
364
365        // Reset cursor if out of bounds
366        let total_count = self.filtered_indices.len()
367            + if self.custom_input_item.is_some() {
368                1
369            } else {
370                0
371            };
372        if self.cursor >= total_count {
373            self.cursor = 0;
374        }
375    }
376
377    /// Get the currently selected item, if any
378    pub fn selected(&self) -> Option<&PickerItem> {
379        // Check if cursor is on custom input item (always at the end)
380        if self.cursor == self.filtered_indices.len() {
381            return self.custom_input_item.as_ref();
382        }
383
384        self.filtered_indices
385            .get(self.cursor)
386            .and_then(|&i| self.items.get(i))
387    }
388
389    /// Get all filtered items with their original indices
390    /// Custom input item (if present) is always last with index usize::MAX
391    pub fn filtered_items(&self) -> impl Iterator<Item = (usize, &PickerItem)> {
392        self.filtered_indices
393            .iter()
394            .filter_map(|&i| self.items.get(i).map(|item| (i, item)))
395            .chain(
396                self.custom_input_item
397                    .as_ref()
398                    .map(|item| (usize::MAX, item)),
399            )
400    }
401
402    /// Move cursor to next item, in a grid the next in its column.
403    pub fn next(&mut self) {
404        if !self.step_in_column(1) {
405            self.step(|cursor, total| (cursor + 1) % total);
406        }
407    }
408
409    /// Move cursor to previous item, in a grid the previous in its column.
410    pub fn previous(&mut self) {
411        if !self.step_in_column(-1) {
412            self.step(|cursor, total| (cursor + total - 1) % total);
413        }
414    }
415
416    /// In a grid, move `step` items along the cursor's column, wrapping round
417    /// its ends, and say so; otherwise do nothing.
418    fn step_in_column(&mut self, step: isize) -> bool {
419        let Some(column) = self.cursor_column() else {
420            return false;
421        };
422        let positions = self.column_positions(column);
423        let row = positions
424            .iter()
425            .position(|&position| position == self.cursor)
426            .unwrap_or(0);
427        let len = positions.len() as isize;
428        self.cursor = positions[(row as isize + step).rem_euclid(len) as usize];
429        true
430    }
431
432    /// Move the cursor by `step` until it is on an item it stops on: any item in
433    /// the list, and in one row only those [`PickerItem::in_one_row`]. If there
434    /// is none, it stays where it is.
435    fn step(&mut self, step: impl Fn(usize, usize) -> usize) {
436        let total = self.filtered_indices.len() + usize::from(self.custom_input_item.is_some());
437        let mut cursor = self.cursor;
438        for _ in 0..total {
439            cursor = step(cursor, total);
440            let stops = !self.is_one_row()
441                || self
442                    .filtered_indices
443                    .get(cursor)
444                    .is_none_or(|&i| self.items[i].in_one_row);
445            if stops {
446                self.cursor = cursor;
447                return;
448            }
449        }
450    }
451
452    /// In one row, the items it stops on, numbered from 0, as (number, position
453    /// in the filtered list).
454    fn one_row_stops(&self) -> impl Iterator<Item = (usize, usize)> + '_ {
455        self.filtered_indices
456            .iter()
457            .enumerate()
458            .filter(|(_, i)| self.items[**i].in_one_row)
459            .map(|(position, _)| position)
460            .enumerate()
461    }
462
463    /// The number of the selected item among those one row stops on, and the
464    /// highest number: what a digit selects. `None` for an item it passes over.
465    pub(crate) fn one_row_number(&self) -> (Option<usize>, usize) {
466        let mut number = None;
467        let mut last = 0;
468        for (n, position) in self.one_row_stops() {
469            if position == self.cursor {
470                number = Some(n);
471            }
472            last = n;
473        }
474        (number, last)
475    }
476
477    /// Move the cursor to the item one row numbers `number`, if there is one.
478    pub(crate) fn select_number(&mut self, number: usize) {
479        let position = self.one_row_stops().find(|(n, _)| *n == number);
480        if let Some((_, position)) = position {
481            self.cursor = position;
482        }
483    }
484
485    /// Get current cursor position
486    pub fn cursor(&self) -> usize {
487        self.cursor
488    }
489
490    /// Get total number of items
491    pub fn total_items(&self) -> usize {
492        self.items.len()
493    }
494
495    /// Get number of filtered items
496    pub fn filtered_count(&self) -> usize {
497        self.filtered_indices.len()
498    }
499
500    /// Get the fuzzy match positions for a given item (for highlighting)
501    pub fn match_indices(&self, item_index: usize) -> Option<Vec<usize>> {
502        let pattern = self.pattern();
503        if pattern.is_empty() {
504            return None;
505        }
506
507        // Don't highlight custom input items (marked with usize::MAX)
508        if item_index == usize::MAX {
509            return None;
510        }
511
512        self.items
513            .get(item_index)
514            .and_then(|item| self.matcher.fuzzy_indices(&item.display, pattern))
515            .map(|(_, indices)| indices)
516    }
517
518    /// Get current status
519    pub fn status(&self) -> &PickerStatus {
520        &self.status
521    }
522
523    /// Mark picker as done (user selected an item)
524    pub fn done(&mut self) {
525        self.status = PickerStatus::Done;
526    }
527
528    /// Mark picker as cancelled (user cancelled)
529    pub fn cancel(&mut self) {
530        self.status = PickerStatus::Cancelled;
531    }
532
533    /// Check if picker is done
534    pub fn is_done(&self) -> bool {
535        self.status == PickerStatus::Done
536    }
537
538    /// Check if picker is cancelled
539    pub fn is_cancelled(&self) -> bool {
540        self.status == PickerStatus::Cancelled
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
548
549    fn create_test_items() -> Vec<PickerItem> {
550        vec![
551            PickerItem::new("main", PickerData::Item("main".to_string())),
552            PickerItem::new("develop", PickerData::Item("develop".to_string())),
553            PickerItem::new("feature/test", PickerData::Item("feature/test".to_string())),
554            PickerItem::new("feature/new", PickerData::Item("feature/new".to_string())),
555            PickerItem::new("bugfix/123", PickerData::Item("bugfix/123".to_string())),
556        ]
557    }
558
559    #[test]
560    fn test_picker_data_display() {
561        let revision = PickerData::Item("main".to_string());
562        assert_eq!(revision.display(), "main");
563
564        let custom = PickerData::CustomInput("custom".to_string());
565        assert_eq!(custom.display(), "custom");
566    }
567
568    #[test]
569    fn test_picker_item_new() {
570        let item = PickerItem::new("main", PickerData::Item("main".to_string()));
571        assert_eq!(item.display.as_ref(), "main");
572        assert_eq!(item.data.display(), "main");
573    }
574
575    #[test]
576    fn test_picker_state_new_without_custom_input() {
577        let items = create_test_items();
578        let state = PickerState::new("Select branch", items.clone(), false);
579
580        assert_eq!(state.prompt_text.as_ref(), "Select branch");
581        assert_eq!(state.total_items(), 5);
582        assert_eq!(state.filtered_count(), 5);
583        assert_eq!(state.cursor(), 0);
584        assert_eq!(state.pattern(), "");
585        assert_eq!(state.status(), &PickerStatus::Active);
586        assert!(!state.allow_custom_input);
587    }
588
589    #[test]
590    fn test_picker_state_new_with_custom_input() {
591        let items = create_test_items();
592        let state = PickerState::new("Select branch", items, true);
593
594        assert!(state.allow_custom_input);
595        assert_eq!(state.custom_input_item, None); // No custom item when pattern is empty
596    }
597
598    #[test]
599    fn test_empty_pattern_shows_all_items() {
600        let items = create_test_items();
601        let state = PickerState::new("Select", items, false);
602
603        assert_eq!(state.filtered_count(), 5);
604        assert_eq!(state.filtered_indices, vec![0, 1, 2, 3, 4]);
605    }
606
607    #[test]
608    fn test_fuzzy_filtering() {
609        let items = create_test_items();
610        let mut state = PickerState::new("Select", items, false);
611
612        state
613            .input_state
614            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
615        state.update_filter();
616
617        // Should match "feature/test", "feature/new", and "bugfix/123" (fuzzy match)
618        assert_eq!(state.filtered_count(), 3);
619
620        let filtered: Vec<_> = state.filtered_items().collect();
621        assert!(
622            filtered
623                .iter()
624                .any(|(_, item)| item.display == "feature/test")
625        );
626        assert!(
627            filtered
628                .iter()
629                .any(|(_, item)| item.display == "feature/new")
630        );
631        assert!(
632            filtered
633                .iter()
634                .any(|(_, item)| item.display == "bugfix/123")
635        );
636    }
637
638    #[test]
639    fn test_fuzzy_filtering_sorts_by_score() {
640        // Create items with varying match quality for pattern "feat"
641        let items = vec![
642            PickerItem::new("feat", PickerData::Item("feat".to_string())), // Exact match
643            PickerItem::new("feature", PickerData::Item("feature".to_string())), // Prefix match
644            PickerItem::new("feature/test", PickerData::Item("feature/test".to_string())), // Prefix with more chars
645            PickerItem::new("fix-eat-bug", PickerData::Item("fix-eat-bug".to_string())), // Scattered match
646        ];
647        let mut state = PickerState::new("Select", items, false);
648
649        // Type "feat" pattern
650        state
651            .input_state
652            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
653        state
654            .input_state
655            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
656        state
657            .input_state
658            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
659        state
660            .input_state
661            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
662        state.update_filter();
663
664        // All should match
665        assert_eq!(state.filtered_count(), 4);
666
667        // Verify sorted by score: exact match > prefix match > scattered match
668        let filtered: Vec<_> = state
669            .filtered_items()
670            .map(|(_, item)| item.display.as_ref())
671            .collect();
672
673        // "feat" (exact) should be first, "fix-eat-bug" (scattered) should be last
674        assert_eq!(filtered[0], "feat");
675        assert_eq!(filtered[filtered.len() - 1], "fix-eat-bug");
676    }
677
678    #[test]
679    fn test_case_insensitive_matching() {
680        let items = vec![
681            PickerItem::new("Feature", PickerData::Item("Feature".to_string())),
682            PickerItem::new("feature", PickerData::Item("feature".to_string())),
683            PickerItem::new("FEATURE", PickerData::Item("FEATURE".to_string())),
684        ];
685
686        let mut state = PickerState::new("Select item", items, false);
687
688        state
689            .input_state
690            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
691        state
692            .input_state
693            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
694        state
695            .input_state
696            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
697        state.update_filter();
698
699        // fuzzy-matcher is case-insensitive by default
700        assert_eq!(state.filtered_count(), 3);
701    }
702
703    #[test]
704    fn test_no_matches() {
705        let items = create_test_items();
706        let mut state = PickerState::new("Select", items, false);
707
708        state
709            .input_state
710            .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
711        state
712            .input_state
713            .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
714        state
715            .input_state
716            .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty()));
717        state.update_filter();
718
719        assert_eq!(state.filtered_count(), 0);
720        assert!(state.selected().is_none());
721    }
722
723    #[test]
724    fn test_custom_input_creation() {
725        let items = create_test_items();
726        let mut state = PickerState::new("Select", items, true);
727
728        // Use pattern "fea" which matches feature/test and feature/new
729        state
730            .input_state
731            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
732        state
733            .input_state
734            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
735        state
736            .input_state
737            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
738        state.update_filter();
739
740        // Custom input item should be created
741        assert!(state.custom_input_item.is_some());
742
743        // Should have multiple regular matches + custom input
744        let filtered: Vec<_> = state.filtered_items().collect();
745        assert!(filtered.len() >= 3); // At least 2 feature items + bugfix + custom input
746
747        // Custom input should be last in filtered items
748        let last = filtered.last().unwrap();
749        assert_eq!(last.0, usize::MAX);
750        assert_eq!(last.1.display.as_ref(), "fea");
751    }
752
753    #[test]
754    fn test_custom_input_not_created_when_disabled() {
755        let items = create_test_items();
756        let mut state = PickerState::new("Select", items, false);
757
758        state
759            .input_state
760            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
761        state
762            .input_state
763            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
764        state.update_filter();
765
766        assert!(state.custom_input_item.is_none());
767    }
768
769    #[test]
770    fn test_custom_input_not_created_on_empty_pattern() {
771        let items = create_test_items();
772        let mut state = PickerState::new("Select", items, true);
773
774        // Start with no input
775        assert!(state.custom_input_item.is_none());
776
777        // Add then remove input
778        state
779            .input_state
780            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
781        state.update_filter();
782        assert!(state.custom_input_item.is_some());
783
784        state
785            .input_state
786            .handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::empty()));
787        state.update_filter();
788        assert!(state.custom_input_item.is_none());
789    }
790
791    #[test]
792    fn test_cursor_next() {
793        let items = create_test_items();
794        let mut state = PickerState::new("Select", items, false);
795
796        assert_eq!(state.cursor(), 0);
797
798        state.next();
799        assert_eq!(state.cursor(), 1);
800
801        state.next();
802        assert_eq!(state.cursor(), 2);
803    }
804
805    #[test]
806    fn test_cursor_next_wraps_around() {
807        let items = create_test_items();
808        let mut state = PickerState::new("Select", items, false);
809
810        // Move to end
811        for _ in 0..5 {
812            state.next();
813        }
814
815        // Should wrap to 0
816        assert_eq!(state.cursor(), 0);
817    }
818
819    #[test]
820    fn test_cursor_previous() {
821        let items = create_test_items();
822        let mut state = PickerState::new("Select", items, false);
823
824        state.next();
825        state.next();
826        assert_eq!(state.cursor(), 2);
827
828        state.previous();
829        assert_eq!(state.cursor(), 1);
830
831        state.previous();
832        assert_eq!(state.cursor(), 0);
833    }
834
835    #[test]
836    fn test_cursor_previous_wraps_around() {
837        let items = create_test_items();
838        let mut state = PickerState::new("Select", items, false);
839
840        assert_eq!(state.cursor(), 0);
841
842        state.previous();
843        // Should wrap to last item
844        assert_eq!(state.cursor(), 4);
845    }
846
847    #[test]
848    fn test_cursor_with_custom_input() {
849        let items = create_test_items();
850        let mut state = PickerState::new("Select", items, true);
851
852        state
853            .input_state
854            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
855        state.update_filter();
856
857        // 3 matched items (feature/test, feature/new, bugfix/123) + 1 custom input = 4 total
858        assert_eq!(state.cursor(), 0);
859
860        state.next();
861        assert_eq!(state.cursor(), 1);
862
863        state.next();
864        assert_eq!(state.cursor(), 2);
865
866        state.next();
867        assert_eq!(state.cursor(), 3); // Custom input position
868
869        state.next();
870        assert_eq!(state.cursor(), 0); // Wrapped around forward
871
872        state.previous();
873        assert_eq!(state.cursor(), 3); // Wrapped around backward to custom input
874    }
875
876    #[test]
877    fn test_navigation_with_custom_input_at_end() {
878        let items = vec![
879            PickerItem::new("feature/a", PickerData::Item("feature/a".to_string())),
880            PickerItem::new("feature/b", PickerData::Item("feature/b".to_string())),
881        ];
882
883        let mut state = PickerState::new("Select", items, true);
884
885        // Type "feat" to get matches + custom input
886        state
887            .input_state
888            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
889        state
890            .input_state
891            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
892        state
893            .input_state
894            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
895        state
896            .input_state
897            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
898        state.update_filter();
899
900        // Should have 2 matches + custom input = 3 total
901        assert_eq!(state.filtered_count(), 2);
902        assert_eq!(state.cursor(), 0);
903
904        // Navigate to first match
905        let selected = state.selected().unwrap();
906        match &selected.data {
907            PickerData::Item(_) => {}
908            _ => panic!("Expected first item to be a revision"),
909        }
910
911        // Navigate to second match
912        state.next();
913        assert_eq!(state.cursor(), 1);
914
915        // Navigate to custom input (last item)
916        state.next();
917        assert_eq!(state.cursor(), 2);
918        let selected = state.selected().unwrap();
919        match &selected.data {
920            PickerData::CustomInput(s) => assert_eq!(s, "feat"),
921            _ => panic!("Expected custom input at end"),
922        }
923
924        // Wrap around to first
925        state.next();
926        assert_eq!(state.cursor(), 0);
927    }
928
929    #[test]
930    fn test_cursor_resets_when_filter_reduces_items() {
931        let items = create_test_items();
932        let mut state = PickerState::new("Select", items, false);
933
934        // Move cursor to position 4
935        for _ in 0..4 {
936            state.next();
937        }
938        assert_eq!(state.cursor(), 4);
939
940        // Filter to only 2 items
941        state
942            .input_state
943            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
944        state.update_filter();
945
946        // Cursor should reset to 0
947        assert_eq!(state.cursor(), 0);
948    }
949
950    #[test]
951    fn test_scroll_through_many_items() {
952        // Create 20 items to test scrolling behavior
953        let items: Vec<_> = (0..20)
954            .map(|i| {
955                PickerItem::new(
956                    format!("branch-{:02}", i),
957                    PickerData::Item(format!("branch-{:02}", i)),
958                )
959            })
960            .collect();
961
962        let mut state = PickerState::new("Select", items, false);
963
964        // Start at first item
965        assert_eq!(state.cursor(), 0);
966
967        // Navigate to middle item
968        for _ in 0..10 {
969            state.next();
970        }
971        assert_eq!(state.cursor(), 10);
972
973        // Navigate to last item
974        for _ in 0..9 {
975            state.next();
976        }
977        assert_eq!(state.cursor(), 19);
978
979        // Wrap around to first
980        state.next();
981        assert_eq!(state.cursor(), 0);
982
983        // Navigate backwards
984        state.previous();
985        assert_eq!(state.cursor(), 19);
986    }
987
988    #[test]
989    fn test_navigation_after_filtering() {
990        let items = vec![
991            PickerItem::new("feature/a", PickerData::Item("feature/a".to_string())),
992            PickerItem::new("feature/b", PickerData::Item("feature/b".to_string())),
993            PickerItem::new("main", PickerData::Item("main".to_string())),
994            PickerItem::new("develop", PickerData::Item("develop".to_string())),
995        ];
996
997        let mut state = PickerState::new("Select", items, false);
998
999        // Filter to get only feature/* branches
1000        state
1001            .input_state
1002            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
1003        state
1004            .input_state
1005            .handle_key_event(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::empty()));
1006        state
1007            .input_state
1008            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
1009        state.update_filter();
1010
1011        // Should have 2 filtered items
1012        assert_eq!(state.filtered_count(), 2);
1013        assert_eq!(state.cursor(), 0);
1014
1015        // Navigate through filtered items
1016        state.next();
1017        assert_eq!(state.cursor(), 1);
1018
1019        // Wrap around
1020        state.next();
1021        assert_eq!(state.cursor(), 0);
1022
1023        // Go backwards
1024        state.previous();
1025        assert_eq!(state.cursor(), 1);
1026    }
1027
1028    #[test]
1029    fn test_selected_returns_correct_item() {
1030        let items = create_test_items();
1031        let mut state = PickerState::new("Select", items, false);
1032
1033        let selected = state.selected().unwrap();
1034        assert_eq!(selected.display.as_ref(), "main");
1035
1036        state.next();
1037        state.next();
1038        let selected = state.selected().unwrap();
1039        assert_eq!(selected.display.as_ref(), "feature/test");
1040    }
1041
1042    #[test]
1043    fn test_selected_with_filter() {
1044        let items = create_test_items();
1045        let mut state = PickerState::new("Select", items, false);
1046
1047        state
1048            .input_state
1049            .handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()));
1050        state.update_filter();
1051
1052        // Should match "bugfix/123"
1053        let selected = state.selected().unwrap();
1054        assert_eq!(selected.display.as_ref(), "bugfix/123");
1055    }
1056
1057    #[test]
1058    fn test_selected_returns_custom_input() {
1059        let items = create_test_items();
1060        let mut state = PickerState::new("Select", items, true);
1061
1062        // Use a pattern that doesn't match any items
1063        state
1064            .input_state
1065            .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()));
1066        state
1067            .input_state
1068            .handle_key_event(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()));
1069        state.update_filter();
1070
1071        // No matches, only custom input at cursor 0
1072        assert_eq!(state.cursor(), 0);
1073        assert_eq!(state.selected().unwrap().display.as_ref(), "qq");
1074    }
1075
1076    #[test]
1077    fn test_select_custom_input() {
1078        let items = create_test_items();
1079        let mut state = PickerState::new("Select", items, true);
1080
1081        // Type a pattern that doesn't match anything
1082        state
1083            .input_state
1084            .handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::empty()));
1085        state
1086            .input_state
1087            .handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::empty()));
1088        state
1089            .input_state
1090            .handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::empty()));
1091        state.update_filter();
1092
1093        // Should have no matches but custom input
1094        assert_eq!(state.filtered_count(), 0);
1095        assert!(state.custom_input_item.is_some());
1096
1097        // Cursor should be at custom input (position 0 since no other items)
1098        assert_eq!(state.cursor(), 0);
1099
1100        // Selected item should be the custom input
1101        let selected = state.selected().unwrap();
1102        assert_eq!(selected.display, "xyz");
1103        match &selected.data {
1104            PickerData::CustomInput(s) => assert_eq!(s, "xyz"),
1105            _ => panic!("Expected CustomInput"),
1106        }
1107
1108        // Mark as done
1109        state.done();
1110        assert!(state.is_done());
1111        assert_eq!(state.status(), &PickerStatus::Done);
1112    }
1113
1114    #[test]
1115    fn test_filtered_items_order() {
1116        let items = create_test_items();
1117        let mut state = PickerState::new("Select", items, true);
1118
1119        state
1120            .input_state
1121            .handle_key_event(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::empty()));
1122        state.update_filter();
1123
1124        let indices: Vec<_> = state.filtered_items().map(|(idx, _)| idx).collect();
1125
1126        // Verify normal item indices followed by custom input
1127        assert_eq!(indices[0], 2); // feature/test
1128        assert_eq!(indices[1], 3); // feature/new
1129        assert_eq!(indices[2], 4); // bugfix/123
1130        assert_eq!(indices[3], usize::MAX); // custom input
1131    }
1132
1133    #[test]
1134    fn test_match_indices_with_pattern() {
1135        let items = create_test_items();
1136        let mut state = PickerState::new("Select", items, false);
1137
1138        state
1139            .input_state
1140            .handle_key_event(KeyEvent::new(KeyCode::Char('m'), KeyModifiers::empty()));
1141        state
1142            .input_state
1143            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
1144        state
1145            .input_state
1146            .handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::empty()));
1147        state.update_filter();
1148
1149        // Get the index of "main" in original items
1150        let indices = state.match_indices(0);
1151        assert!(indices.is_some());
1152
1153        let indices = indices.unwrap();
1154        assert_eq!(indices.len(), 3); // 'm', 'a', 'i' should match
1155    }
1156
1157    #[test]
1158    fn test_match_indices_empty_pattern() {
1159        let items = create_test_items();
1160        let state = PickerState::new("Select", items, false);
1161
1162        let indices = state.match_indices(0);
1163        assert!(indices.is_none());
1164    }
1165
1166    #[test]
1167    fn test_match_indices_custom_input_returns_none() {
1168        let items = create_test_items();
1169        let mut state = PickerState::new("Select", items, true);
1170
1171        state
1172            .input_state
1173            .handle_key_event(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::empty()));
1174        state.update_filter();
1175
1176        // usize::MAX is used for custom input items
1177        let indices = state.match_indices(usize::MAX);
1178        assert!(indices.is_none());
1179    }
1180
1181    #[test]
1182    fn test_status_transitions() {
1183        let items = create_test_items();
1184        let mut state = PickerState::new("Select", items, false);
1185
1186        assert_eq!(state.status(), &PickerStatus::Active);
1187        assert!(!state.is_done());
1188        assert!(!state.is_cancelled());
1189
1190        state.done();
1191        assert_eq!(state.status(), &PickerStatus::Done);
1192        assert!(state.is_done());
1193        assert!(!state.is_cancelled());
1194    }
1195
1196    #[test]
1197    fn test_status_cancelled() {
1198        let items = create_test_items();
1199        let mut state = PickerState::new("Select", items, false);
1200
1201        state.cancel();
1202        assert_eq!(state.status(), &PickerStatus::Cancelled);
1203        assert!(!state.is_done());
1204        assert!(state.is_cancelled());
1205    }
1206
1207    #[test]
1208    fn test_empty_items_list() {
1209        let state = PickerState::new("Select", vec![], false);
1210
1211        assert_eq!(state.total_items(), 0);
1212        assert_eq!(state.filtered_count(), 0);
1213        assert_eq!(state.cursor(), 0);
1214        assert!(state.selected().is_none());
1215    }
1216
1217    #[test]
1218    fn test_empty_items_with_custom_input() {
1219        let mut state = PickerState::new("Select", vec![], true);
1220
1221        state
1222            .input_state
1223            .handle_key_event(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
1224        state.update_filter();
1225
1226        assert_eq!(state.total_items(), 0);
1227        assert_eq!(state.filtered_count(), 0);
1228        assert!(state.custom_input_item.is_some());
1229
1230        assert_eq!(state.selected().unwrap().display.as_ref(), "a");
1231    }
1232
1233    #[test]
1234    fn test_cursor_navigation_empty_list() {
1235        let mut state = PickerState::new("Select", vec![], false);
1236
1237        state.next();
1238        assert_eq!(state.cursor(), 0);
1239
1240        state.previous();
1241        assert_eq!(state.cursor(), 0);
1242    }
1243
1244    #[test]
1245    fn test_single_item_navigation() {
1246        let items = vec![PickerItem::new(
1247            "only",
1248            PickerData::Item("only".to_string()),
1249        )];
1250        let mut state = PickerState::new("Select", items, false);
1251
1252        assert_eq!(state.cursor(), 0);
1253
1254        state.next();
1255        assert_eq!(state.cursor(), 0); // Wraps to same item
1256
1257        state.previous();
1258        assert_eq!(state.cursor(), 0); // Wraps to same item
1259    }
1260
1261    #[test]
1262    fn test_with_refs_basic_sorting() {
1263        // Test that items are sorted as: default -> branches -> tags -> remotes
1264        let refs = &[
1265            Ref::Tag("v1.0.0".to_string()),
1266            Ref::Remote("origin/main".to_string()),
1267            Ref::Head("feature".to_string()),
1268            Ref::Head("main".to_string()),
1269            Ref::Tag("v2.0.0".to_string()),
1270        ];
1271
1272        let state = PickerState::with_refs(PickerParams {
1273            prompt: "Select".into(),
1274            refs,
1275            exclude_ref: None,
1276            default: None,
1277            allow_custom_input: false,
1278        });
1279
1280        let items: Vec<_> = state
1281            .filtered_items()
1282            .map(|(_, item)| item.display.as_ref())
1283            .collect();
1284
1285        // Order is preserved from input
1286        assert_eq!(
1287            items,
1288            &[
1289                "tag: v1.0.0",
1290                "origin/main",
1291                "feature",
1292                "main",
1293                "tag: v2.0.0"
1294            ]
1295        )
1296    }
1297
1298    #[test]
1299    fn test_with_refs_empty_list() {
1300        let state = PickerState::with_refs(PickerParams {
1301            prompt: "Select".into(),
1302            refs: &[],
1303            exclude_ref: None,
1304            default: None,
1305            allow_custom_input: false,
1306        });
1307
1308        assert_eq!(state.total_items(), 0);
1309        assert_eq!(state.filtered_count(), 0);
1310        assert!(state.selected().is_none());
1311    }
1312
1313    #[test]
1314    fn test_with_refs_with_default() {
1315        // Test that default ref is shown first
1316        let refs = &[
1317            Ref::Head("feature".to_string()),
1318            Ref::Head("main".to_string()),
1319            Ref::Tag("v1.0.0".to_string()),
1320        ];
1321
1322        let default = Some(Rev::Ref(Ref::Head("main".to_string())));
1323        let state = PickerState::with_refs(PickerParams {
1324            prompt: "Select".into(),
1325            refs,
1326            exclude_ref: None,
1327            default,
1328            allow_custom_input: false,
1329        });
1330
1331        let items: Vec<_> = state
1332            .filtered_items()
1333            .map(|(_, item)| item.display.as_ref())
1334            .collect();
1335
1336        // Default should be first, then remaining in input order
1337        assert_eq!(&items, &["main", "feature", "tag: v1.0.0"]);
1338    }
1339
1340    #[test]
1341    fn test_with_refs_exclude_ref() {
1342        // Test that excluded ref is not shown
1343        let refs = &[
1344            Ref::Head("feature".to_string()),
1345            Ref::Head("main".to_string()),
1346            Ref::Tag("v1.0.0".to_string()),
1347        ];
1348
1349        let state = PickerState::with_refs(PickerParams {
1350            prompt: "Select".into(),
1351            refs,
1352            exclude_ref: Some(Ref::Head("main".to_string())),
1353            default: None,
1354            allow_custom_input: false,
1355        });
1356
1357        let items: Vec<_> = state
1358            .filtered_items()
1359            .map(|(_, item)| item.display.as_ref())
1360            .collect();
1361
1362        // main should be excluded
1363        assert_eq!(&items, &["feature", "tag: v1.0.0"])
1364    }
1365
1366    #[test]
1367    fn test_with_refs_duplicate_names() {
1368        // Tag and branch with same name are disambiguated by "tag: " prefix
1369        let refs = &[
1370            Ref::Head("v1.0.0".to_string()),
1371            Ref::Tag("v1.0.0".to_string()),
1372            Ref::Head("main".to_string()),
1373        ];
1374
1375        let state = PickerState::with_refs(PickerParams {
1376            prompt: "Select".into(),
1377            refs,
1378            exclude_ref: None,
1379            default: None,
1380            allow_custom_input: false,
1381        });
1382
1383        let items: Vec<_> = state
1384            .filtered_items()
1385            .map(|(_, item)| item.display.as_ref())
1386            .collect();
1387
1388        assert_eq!(&items, &["v1.0.0", "tag: v1.0.0", "main"])
1389    }
1390
1391    #[test]
1392    fn test_with_refs_duplicate_with_default() {
1393        // Tag default shown with "tag: " prefix
1394        let refs = &[
1395            Ref::Head("v1.0.0".to_string()),
1396            Ref::Tag("v1.0.0".to_string()),
1397            Ref::Head("main".to_string()),
1398        ];
1399
1400        let state = PickerState::with_refs(PickerParams {
1401            prompt: "Select".into(),
1402            refs,
1403            exclude_ref: None,
1404            default: Some(Rev::Ref(Ref::Tag("v1.0.0".to_string()))),
1405            allow_custom_input: false,
1406        });
1407
1408        let items: Vec<_> = state
1409            .filtered_items()
1410            .map(|(_, item)| item.display.as_ref())
1411            .collect();
1412
1413        assert_eq!(items, &["tag: v1.0.0", "v1.0.0", "main"])
1414    }
1415
1416    #[test]
1417    fn test_with_refs_exclude_and_default_same() {
1418        // Test when exclude and default are the same
1419        // The implementation adds default first, then filters exclude from remaining items
1420        // So if default == exclude, default is still added, then exclude filters it from the rest
1421        let refs = &[
1422            Ref::Head("feature".to_string()),
1423            Ref::Head("main".to_string()),
1424        ];
1425
1426        let state = PickerState::with_refs(PickerParams {
1427            prompt: "Select".into(),
1428            refs,
1429            exclude_ref: Some(Ref::Head("main".to_string())),
1430            default: Some(Rev::Ref(Ref::Head("main".to_string()))),
1431            allow_custom_input: false,
1432        });
1433
1434        let items: Vec<_> = state
1435            .filtered_items()
1436            .map(|(_, item)| item.display.as_ref())
1437            .collect();
1438
1439        // Default is added first, then exclude filters remaining items
1440        // So we get: main (default) + feature (not excluded)
1441        assert_eq!(&items, &["main", "feature"])
1442    }
1443
1444    #[test]
1445    fn test_with_refs_with_custom_input() {
1446        let refs = &[
1447            Ref::Head("main".to_string()),
1448            Ref::Tag("v1.0.0".to_string()),
1449        ];
1450
1451        let state = PickerState::with_refs(PickerParams {
1452            prompt: "Select".into(),
1453            refs,
1454            exclude_ref: None,
1455            default: None,
1456            allow_custom_input: true,
1457        });
1458
1459        // Custom input should be allowed
1460        assert!(state.allow_custom_input);
1461
1462        // With empty pattern, no custom input item
1463        assert!(state.custom_input_item.is_none());
1464    }
1465
1466    #[test]
1467    fn test_with_refs_exclude_tag_sibling() {
1468        // If branch is excluded, remaining tag still shows "tag: " prefix
1469        let refs = &[
1470            Ref::Head("v1.0.0".to_string()),
1471            Ref::Tag("v1.0.0".to_string()),
1472        ];
1473
1474        let state = PickerState::with_refs(PickerParams {
1475            prompt: "Select".into(),
1476            refs,
1477            exclude_ref: Some(Ref::Head("v1.0.0".to_string())),
1478            default: None,
1479            allow_custom_input: false,
1480        });
1481
1482        let items: Vec<_> = state
1483            .filtered_items()
1484            .map(|(_, item)| item.display.as_ref())
1485            .collect();
1486
1487        assert_eq!(&items, &["tag: v1.0.0"]);
1488    }
1489
1490    #[test]
1491    fn test_with_refs_remote_tracking_default() {
1492        let refs = &[
1493            Ref::Head("main".to_string()),
1494            Ref::Remote("origin/main".to_string()),
1495            Ref::Remote("origin/feature".to_string()),
1496        ];
1497
1498        let state = PickerState::with_refs(PickerParams {
1499            prompt: "Select".into(),
1500            refs,
1501            exclude_ref: None,
1502            default: Some(Rev::Ref(Ref::Remote("origin/main".to_string()))),
1503            allow_custom_input: false,
1504        });
1505
1506        let items: Vec<_> = state
1507            .filtered_items()
1508            .map(|(_, item)| item.display.as_ref())
1509            .collect();
1510
1511        // Default remote-tracking branch shown first as shorthand, then remaining
1512        assert_eq!(&items, &["origin/main", "main", "origin/feature"]);
1513    }
1514}