1use std::{io, ops::Rem};
2
3use console::{Key, Term};
4use fuzzy_matcher::FuzzyMatcher;
5
6use crate::{
7 theme::{render::TermThemeRenderer, SimpleTheme, Theme},
8 Result,
9};
10
11#[derive(Clone)]
34pub struct FuzzySelect<'a> {
35 default: Option<usize>,
36 items: Vec<String>,
37 prompt: String,
38 report: bool,
39 clear: bool,
40 highlight_matches: bool,
41 enable_vim_mode: bool,
42 max_length: Option<usize>,
43 theme: &'a dyn Theme,
44 initial_text: String,
47}
48
49impl Default for FuzzySelect<'static> {
50 fn default() -> Self {
51 Self::new()
52 }
53}
54
55impl FuzzySelect<'static> {
56 pub fn new() -> Self {
58 Self::with_theme(&SimpleTheme)
59 }
60}
61
62impl FuzzySelect<'_> {
63 pub fn clear(mut self, val: bool) -> Self {
67 self.clear = val;
68 self
69 }
70
71 pub fn default(mut self, val: usize) -> Self {
73 self.default = Some(val);
74 self
75 }
76
77 pub fn item<T: ToString>(mut self, item: T) -> Self {
79 self.items.push(item.to_string());
80 self
81 }
82
83 pub fn items<T, I>(mut self, items: I) -> Self
85 where
86 T: ToString,
87 I: IntoIterator<Item = T>,
88 {
89 self.items
90 .extend(items.into_iter().map(|item| item.to_string()));
91
92 self
93 }
94
95 pub fn with_initial_text<S: Into<String>>(mut self, initial_text: S) -> Self {
97 self.initial_text = initial_text.into();
98 self
99 }
100
101 pub fn with_prompt<S: Into<String>>(mut self, prompt: S) -> Self {
106 self.prompt = prompt.into();
107 self
108 }
109
110 pub fn report(mut self, val: bool) -> Self {
114 self.report = val;
115 self
116 }
117
118 pub fn highlight_matches(mut self, val: bool) -> Self {
122 self.highlight_matches = val;
123 self
124 }
125
126 pub fn vim_mode(mut self, val: bool) -> Self {
133 self.enable_vim_mode = val;
134 self
135 }
136
137 pub fn max_length(mut self, rows: usize) -> Self {
141 self.max_length = Some(rows);
142 self
143 }
144
145 #[inline]
152 pub fn interact(self) -> Result<usize> {
153 self.interact_on(&Term::stderr())
154 }
155
156 #[inline]
182 pub fn interact_opt(self) -> Result<Option<usize>> {
183 self.interact_on_opt(&Term::stderr())
184 }
185
186 #[inline]
188 pub fn interact_on(self, term: &Term) -> Result<usize> {
189 Ok(self
190 ._interact_on(term, false)?
191 .ok_or_else(|| io::Error::new(io::ErrorKind::Other, "Quit not allowed in this case"))?)
192 }
193
194 #[inline]
196 pub fn interact_on_opt(self, term: &Term) -> Result<Option<usize>> {
197 self._interact_on(term, true)
198 }
199
200 fn _interact_on(self, term: &Term, allow_quit: bool) -> Result<Option<usize>> {
201 let mut cursor = self.initial_text.chars().count();
203 let mut search_term = self.initial_text.to_owned();
204
205 let mut render = TermThemeRenderer::new(term, self.theme);
206 let mut sel = self.default;
207
208 let mut size_vec = Vec::new();
209 for items in self.items.iter().as_slice() {
210 let size = &items.len();
211 size_vec.push(*size);
212 }
213
214 let matcher = fuzzy_matcher::skim::SkimMatcherV2::default();
216
217 let visible_term_rows = (term.size().0 as usize).max(3) - 2;
219 let visible_term_rows = self
220 .max_length
221 .unwrap_or(visible_term_rows)
222 .min(visible_term_rows);
223 let mut starting_row = 0;
225
226 term.hide_cursor()?;
227
228 let mut vim_mode = false;
229
230 loop {
231 let mut byte_indices = search_term
232 .char_indices()
233 .map(|(index, _)| index)
234 .collect::<Vec<_>>();
235
236 byte_indices.push(search_term.len());
237
238 render.clear()?;
239 render.fuzzy_select_prompt(self.prompt.as_str(), &search_term, byte_indices[cursor])?;
240
241 let mut filtered_list = self
243 .items
244 .iter()
245 .map(|item| (item, matcher.fuzzy_match(item, &search_term)))
246 .filter_map(|(item, score)| score.map(|s| (item, s)))
247 .collect::<Vec<_>>();
248
249 filtered_list.sort_unstable_by(|(_, s1), (_, s2)| s2.cmp(s1));
251
252 for (idx, (item, _)) in filtered_list
253 .iter()
254 .enumerate()
255 .skip(starting_row)
256 .take(visible_term_rows)
257 {
258 render.fuzzy_select_prompt_item(
259 item,
260 Some(idx) == sel,
261 self.highlight_matches,
262 &matcher,
263 &search_term,
264 )?;
265 }
266 term.flush()?;
267
268 match (term.read_key()?, sel, vim_mode) {
269 (Key::Escape, _, false) if self.enable_vim_mode => {
270 vim_mode = true;
271 }
272 (Key::Escape, _, false) | (Key::Char('q'), _, true) if allow_quit => {
273 if self.clear {
274 render.clear()?;
275 term.flush()?;
276 }
277 term.show_cursor()?;
278 return Ok(None);
279 }
280 (Key::Char('i' | 'a'), _, true) => {
281 vim_mode = false;
282 }
283 (Key::ArrowUp | Key::BackTab, _, _) | (Key::Char('k'), _, true)
284 if !filtered_list.is_empty() =>
285 {
286 if sel == Some(0) {
287 starting_row =
288 filtered_list.len().max(visible_term_rows) - visible_term_rows;
289 } else if sel == Some(starting_row) {
290 starting_row -= 1;
291 }
292 sel = match sel {
293 None => Some(filtered_list.len() - 1),
294 Some(sel) => Some(
295 ((sel as i64 - 1 + filtered_list.len() as i64)
296 % (filtered_list.len() as i64))
297 as usize,
298 ),
299 };
300 term.flush()?;
301 }
302 (Key::ArrowDown | Key::Tab, _, _) | (Key::Char('j'), _, true)
303 if !filtered_list.is_empty() =>
304 {
305 sel = match sel {
306 None => Some(0),
307 Some(sel) => {
308 Some((sel as u64 + 1).rem(filtered_list.len() as u64) as usize)
309 }
310 };
311 if sel == Some(visible_term_rows + starting_row) {
312 starting_row += 1;
313 } else if sel == Some(0) {
314 starting_row = 0;
315 }
316 term.flush()?;
317 }
318 (Key::ArrowLeft, _, _) | (Key::Char('h'), _, true) if cursor > 0 => {
319 cursor -= 1;
320 term.flush()?;
321 }
322 (Key::ArrowRight, _, _) | (Key::Char('l'), _, true)
323 if cursor < byte_indices.len() - 1 =>
324 {
325 cursor += 1;
326 term.flush()?;
327 }
328 (Key::Enter, Some(sel), _) if !filtered_list.is_empty() => {
329 if self.clear {
330 render.clear()?;
331 }
332
333 if self.report {
334 render
335 .input_prompt_selection(self.prompt.as_str(), filtered_list[sel].0)?;
336 }
337
338 let sel_string = filtered_list[sel].0;
339 let sel_string_pos_in_items =
340 self.items.iter().position(|item| item.eq(sel_string));
341
342 term.show_cursor()?;
343 return Ok(sel_string_pos_in_items);
344 }
345 (Key::Backspace, _, _) if cursor > 0 => {
346 cursor -= 1;
347 search_term.remove(byte_indices[cursor]);
348 term.flush()?;
349 }
350 (Key::Del, _, _) if cursor < byte_indices.len() - 1 => {
351 search_term.remove(byte_indices[cursor]);
352 term.flush()?;
353 }
354 (Key::Char(chr), _, _) if !chr.is_ascii_control() => {
355 search_term.insert(byte_indices[cursor], chr);
356 cursor += 1;
357 term.flush()?;
358 sel = Some(0);
359 starting_row = 0;
360 }
361
362 _ => {}
363 }
364
365 render.clear_preserve_prompt(&size_vec)?;
366 }
367 }
368}
369
370impl<'a> FuzzySelect<'a> {
371 pub fn with_theme(theme: &'a dyn Theme) -> Self {
386 Self {
387 default: None,
388 items: vec![],
389 prompt: "".into(),
390 report: true,
391 clear: true,
392 highlight_matches: true,
393 enable_vim_mode: false,
394 max_length: None,
395 theme,
396 initial_text: "".into(),
397 }
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 #[test]
406 fn test_clone() {
407 let fuzzy_select = FuzzySelect::new().with_prompt("Do you want to continue?");
408
409 let _ = fuzzy_select.clone();
410 }
411
412 #[test]
413 fn test_iterator() {
414 let items = ["First", "Second", "Third"];
415 let iterator = items.iter().skip(1);
416
417 assert_eq!(FuzzySelect::new().items(iterator).items, &items[1..]);
418 }
419}