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