Skip to main content

tui/
combobox.rs

1use crate::components::ViewContext;
2use crate::components::component::PickerMessage;
3use crate::fuzzy_matcher::{FuzzyMatcher, Searchable};
4use crate::line::Line;
5use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6use std::cmp::Ordering;
7
8const DEFAULT_MAX_VISIBLE: usize = 10;
9
10pub enum PickerKey {
11    Escape,
12    MoveUp,
13    MoveDown,
14    MoveLeft,
15    MoveRight,
16    Tab,
17    BackTab,
18    Confirm,
19    Char(char),
20    Backspace,
21    BackspaceOnEmpty,
22    ControlChar,
23    Other,
24}
25
26pub struct Combobox<T: Searchable + Send + Sync + 'static> {
27    fuzzy: FuzzyMatcher<T>,
28    selected_index: usize,
29    scroll_offset: usize,
30    max_visible: usize,
31}
32
33impl<T: Searchable + Send + Sync + 'static> Combobox<T> {
34    pub fn new(items: Vec<T>) -> Self {
35        Self {
36            fuzzy: FuzzyMatcher::new(items),
37            selected_index: 0,
38            scroll_offset: 0,
39            max_visible: DEFAULT_MAX_VISIBLE,
40        }
41    }
42
43    pub fn from_matches(matches: Vec<T>) -> Self {
44        Self {
45            fuzzy: FuzzyMatcher::from_matches(matches),
46            selected_index: 0,
47            scroll_offset: 0,
48            max_visible: DEFAULT_MAX_VISIBLE,
49        }
50    }
51
52    pub fn query(&self) -> &str {
53        self.fuzzy.query()
54    }
55
56    pub fn matches(&self) -> &[T] {
57        self.fuzzy.matches()
58    }
59
60    pub fn selected_index(&self) -> usize {
61        self.selected_index
62    }
63
64    pub fn set_max_visible(&mut self, max: usize) {
65        self.max_visible = max;
66        self.ensure_visible();
67    }
68
69    pub fn set_match_sort(&mut self, sort: fn(&T, &T) -> Ordering) {
70        self.fuzzy.set_match_sort(sort);
71        self.scroll_offset = 0;
72        if self.selected_index >= self.fuzzy.matches().len() {
73            self.selected_index = 0;
74        }
75        self.ensure_visible();
76    }
77
78    pub fn is_empty(&self) -> bool {
79        self.fuzzy.is_empty()
80    }
81
82    pub fn selected(&self) -> Option<&T> {
83        self.fuzzy.matches().get(self.selected_index)
84    }
85
86    pub fn push_query_char(&mut self, c: char) {
87        self.fuzzy.push_query_char(c);
88        self.reset_viewport();
89    }
90
91    pub fn pop_query_char(&mut self) {
92        if self.fuzzy.pop_query_char() {
93            self.reset_viewport();
94        }
95    }
96
97    pub fn set_selected_index(&mut self, index: usize) {
98        let len = self.fuzzy.matches().len();
99        if len == 0 {
100            return;
101        }
102        self.selected_index = index.min(len - 1);
103        self.ensure_visible();
104    }
105
106    pub fn move_up(&mut self) {
107        self.move_up_where(|_| true);
108    }
109
110    pub fn move_down(&mut self) {
111        self.move_down_where(|_| true);
112    }
113
114    pub fn move_up_where(&mut self, predicate: impl Fn(&T) -> bool) {
115        let len = self.fuzzy.matches().len();
116        if len == 0 {
117            return;
118        }
119        let matches = self.fuzzy.matches();
120        let mut idx = self.selected_index;
121        for _ in 0..len {
122            idx = if idx == 0 { len - 1 } else { idx - 1 };
123            if predicate(&matches[idx]) {
124                self.selected_index = idx;
125                self.ensure_visible();
126                return;
127            }
128        }
129    }
130
131    pub fn move_down_where(&mut self, predicate: impl Fn(&T) -> bool) {
132        let len = self.fuzzy.matches().len();
133        if len == 0 {
134            return;
135        }
136        let matches = self.fuzzy.matches();
137        let mut idx = self.selected_index;
138        for _ in 0..len {
139            idx = (idx + 1) % len;
140            if predicate(&matches[idx]) {
141                self.selected_index = idx;
142                self.ensure_visible();
143                return;
144            }
145        }
146    }
147
148    pub fn select_first_where(&mut self, predicate: impl Fn(&T) -> bool) {
149        if let Some(idx) = self.fuzzy.matches().iter().position(&predicate) {
150            self.selected_index = idx;
151            self.ensure_visible();
152        }
153    }
154
155    pub fn render_items(
156        &self,
157        context: &ViewContext,
158        render_item: impl Fn(&T, bool, &ViewContext) -> Line,
159    ) -> Vec<Line> {
160        let inner = context.with_size((context.size.width.saturating_sub(2), context.size.height));
161        self.visible_matches_with_selection()
162            .into_iter()
163            .map(|(item, is_selected)| render_item(item, is_selected, &inner).prepend("  "))
164            .collect()
165    }
166
167    pub fn visible_matches_with_selection(&self) -> Vec<(&T, bool)> {
168        let visible_selected_index = self.visible_selected_index();
169        self.visible_matches()
170            .iter()
171            .enumerate()
172            .map(|(i, item)| (item, Some(i) == visible_selected_index))
173            .collect()
174    }
175
176    /// Standard event dispatch for picker-style components.
177    ///
178    /// Handles Escape, Up/Down, Enter (confirm), Char (query + whitespace-close),
179    /// Backspace, and `BackspaceOnEmpty`. Returns `PickerMessage<T>` for each action.
180    pub fn handle_picker_event(
181        &mut self,
182        event: &crate::components::Event,
183    ) -> Option<Vec<PickerMessage<T>>> {
184        let crate::components::Event::Key(key_event) = event else {
185            return None;
186        };
187        match classify_key(*key_event, self.fuzzy.query().is_empty()) {
188            PickerKey::Escape => Some(vec![PickerMessage::Close]),
189            PickerKey::BackspaceOnEmpty => Some(vec![PickerMessage::CloseAndPopChar]),
190            PickerKey::MoveUp => {
191                self.move_up();
192                Some(vec![])
193            }
194            PickerKey::MoveDown => {
195                self.move_down();
196                Some(vec![])
197            }
198            PickerKey::Confirm => {
199                if let Some(item) = self.selected().cloned() {
200                    Some(vec![PickerMessage::Confirm(item)])
201                } else {
202                    Some(vec![PickerMessage::Close])
203                }
204            }
205            PickerKey::Char(c) => {
206                if c.is_whitespace() {
207                    return Some(vec![PickerMessage::CloseWithChar(c)]);
208                }
209                self.push_query_char(c);
210                Some(vec![PickerMessage::CharTyped(c)])
211            }
212            PickerKey::Backspace => {
213                self.pop_query_char();
214                Some(vec![PickerMessage::PopChar])
215            }
216            PickerKey::MoveLeft
217            | PickerKey::MoveRight
218            | PickerKey::Tab
219            | PickerKey::BackTab
220            | PickerKey::ControlChar
221            | PickerKey::Other => Some(vec![]),
222        }
223    }
224
225    fn visible_matches(&self) -> &[T] {
226        let matches = self.fuzzy.matches();
227        let end = (self.scroll_offset + self.max_visible).min(matches.len());
228        &matches[self.scroll_offset..end]
229    }
230
231    fn visible_selected_index(&self) -> Option<usize> {
232        self.selected_index.checked_sub(self.scroll_offset)
233    }
234
235    fn ensure_visible(&mut self) {
236        if self.fuzzy.matches().is_empty() {
237            self.scroll_offset = 0;
238            return;
239        }
240        if self.selected_index < self.scroll_offset {
241            self.scroll_offset = self.selected_index;
242        } else if self.selected_index >= self.scroll_offset + self.max_visible {
243            self.scroll_offset = self.selected_index + 1 - self.max_visible;
244        }
245    }
246
247    fn reset_viewport(&mut self) {
248        self.scroll_offset = 0;
249        if self.selected_index >= self.fuzzy.matches().len() {
250            self.selected_index = 0;
251        }
252    }
253}
254
255pub fn classify_key(key: KeyEvent, query_is_empty: bool) -> PickerKey {
256    match key.code {
257        KeyCode::Esc => PickerKey::Escape,
258        KeyCode::Up => PickerKey::MoveUp,
259        KeyCode::Down => PickerKey::MoveDown,
260        KeyCode::Left => PickerKey::MoveLeft,
261        KeyCode::Right => PickerKey::MoveRight,
262        KeyCode::Tab => PickerKey::Tab,
263        KeyCode::BackTab => PickerKey::BackTab,
264        KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => PickerKey::MoveUp,
265        KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => PickerKey::MoveDown,
266        KeyCode::Enter => PickerKey::Confirm,
267        KeyCode::Char(c) if c.is_control() => PickerKey::ControlChar,
268        KeyCode::Char(c) => PickerKey::Char(c),
269        KeyCode::Backspace if query_is_empty => PickerKey::BackspaceOnEmpty,
270        KeyCode::Backspace => PickerKey::Backspace,
271        _ => PickerKey::Other,
272    }
273}