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