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 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}