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