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 #[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 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 if let crate::components::Event::Paste(text) = event {
197 let sanitized: String = text.chars().filter(|c| !c.is_control()).collect();
198 for c in sanitized.chars() {
199 self.push_query_char(c);
200 }
201 return Some(if sanitized.is_empty() { vec![] } else { vec![PickerMessage::TextTyped(sanitized)] });
202 }
203 return None;
204 };
205 match classify_key(*key_event, self.fuzzy.query().is_empty()) {
206 PickerKey::Escape => Some(vec![PickerMessage::Close]),
207 PickerKey::BackspaceOnEmpty => Some(vec![PickerMessage::CloseAndPopChar]),
208 PickerKey::MoveUp => {
209 self.move_up();
210 Some(vec![])
211 }
212 PickerKey::MoveDown => {
213 self.move_down();
214 Some(vec![])
215 }
216 PickerKey::Tab | PickerKey::Confirm => {
217 if let Some(item) = self.selected().cloned() {
218 Some(vec![PickerMessage::Confirm(item)])
219 } else {
220 Some(vec![PickerMessage::Close])
221 }
222 }
223 PickerKey::Char(c) => {
224 if c.is_whitespace() && self.close_on_whitespace {
225 return Some(vec![PickerMessage::CloseWithChar(c)]);
226 }
227 self.push_query_char(c);
228 Some(vec![PickerMessage::CharTyped(c)])
229 }
230 PickerKey::Backspace => {
231 self.pop_query_char();
232 Some(vec![PickerMessage::PopChar])
233 }
234 PickerKey::MoveLeft
235 | PickerKey::MoveRight
236 | PickerKey::BackTab
237 | PickerKey::ControlChar
238 | PickerKey::Other => Some(vec![]),
239 }
240 }
241
242 fn visible_matches(&self) -> &[T] {
243 let matches = self.fuzzy.matches();
244 let end = (self.scroll_offset + self.max_visible).min(matches.len());
245 &matches[self.scroll_offset..end]
246 }
247
248 fn visible_selected_index(&self) -> Option<usize> {
249 self.selected_index.checked_sub(self.scroll_offset)
250 }
251
252 fn ensure_visible(&mut self) {
253 if self.fuzzy.matches().is_empty() {
254 self.scroll_offset = 0;
255 return;
256 }
257 if self.selected_index < self.scroll_offset {
258 self.scroll_offset = self.selected_index;
259 } else if self.selected_index >= self.scroll_offset + self.max_visible {
260 self.scroll_offset = self.selected_index + 1 - self.max_visible;
261 }
262 }
263
264 fn reset_viewport(&mut self) {
265 self.scroll_offset = 0;
266 if self.selected_index >= self.fuzzy.matches().len() {
267 self.selected_index = 0;
268 }
269 }
270}
271
272pub fn classify_key(key: KeyEvent, query_is_empty: bool) -> PickerKey {
273 match key.code {
274 KeyCode::Esc => PickerKey::Escape,
275 KeyCode::Up => PickerKey::MoveUp,
276 KeyCode::Down => PickerKey::MoveDown,
277 KeyCode::Left => PickerKey::MoveLeft,
278 KeyCode::Right => PickerKey::MoveRight,
279 KeyCode::Tab => PickerKey::Tab,
280 KeyCode::BackTab => PickerKey::BackTab,
281 KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => PickerKey::MoveUp,
282 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => PickerKey::MoveDown,
283 KeyCode::Enter => PickerKey::Confirm,
284 KeyCode::Char(c) if c.is_control() => PickerKey::ControlChar,
285 KeyCode::Char(c) => PickerKey::Char(c),
286 KeyCode::Backspace if query_is_empty => PickerKey::BackspaceOnEmpty,
287 KeyCode::Backspace => PickerKey::Backspace,
288 _ => PickerKey::Other,
289 }
290}