1use std::{
2 cmp::{max, min},
3 fs::canonicalize,
4 path::PathBuf,
5 sync::Arc,
6 thread::{available_parallelism, spawn},
7};
8
9use anyhow::Result;
10use nucleo::{pattern, Config, Injector, Nucleo, Utf32String};
11use ratatui::{
12 style::{Color, Modifier, Style},
13 text::{Line, Span},
14};
15use tokio::process::Command as TokioCommand;
16use unicode_segmentation::UnicodeSegmentation;
17use walkdir::WalkDir;
18
19use crate::modes::{extract_extension, ContentWindow, Icon, Input};
20use crate::{
21 config::{with_icon, with_icon_metadata},
22 io::inject_command,
23 modes::FileKind,
24};
25
26pub enum Direction {
30 Up,
31 Down,
32 PageUp,
33 PageDown,
34 Start,
35 End,
36 Index(u16),
37}
38
39pub enum FuzzyKind {
43 File,
44 Line,
45 Action,
46}
47
48impl FuzzyKind {
49 pub fn is_file(&self) -> bool {
50 matches!(self, Self::File)
51 }
52}
53
54pub struct FuzzyFinder<String: Sync + Send + 'static> {
70 pub kind: FuzzyKind,
75 pub matcher: Nucleo<String>,
77 selected: Option<std::string::String>,
79 pub input: Input,
81 pub item_count: u32,
83 pub matched_item_count: u32,
85 pub index: u32,
87 top: u32,
89 height: u32,
91}
92
93impl<String: Sync + Send + 'static> Default for FuzzyFinder<String>
94where
95 Vec<String>: FromIterator<std::string::String>,
96{
97 fn default() -> Self {
98 let config = Config::DEFAULT.match_paths();
99 Self::build(config, FuzzyKind::File)
100 }
101}
102
103impl<String: Sync + Send + 'static> FuzzyFinder<String>
104where
105 Vec<String>: FromIterator<std::string::String>,
106{
107 fn default_thread_count() -> Option<usize> {
108 available_parallelism()
109 .map(|it| it.get().checked_sub(2).unwrap_or(1))
110 .ok()
111 }
112
113 fn build_nucleo(config: Config) -> Nucleo<String> {
114 Nucleo::new(config, Arc::new(|| {}), Self::default_thread_count(), 1)
115 }
116
117 pub fn new(kind: FuzzyKind) -> Self {
119 match kind {
120 FuzzyKind::File => Self::default(),
121 FuzzyKind::Line => Self::for_lines(),
122 FuzzyKind::Action => Self::for_help(),
123 }
124 }
125
126 fn build(config: Config, kind: FuzzyKind) -> Self {
127 Self {
128 matcher: Self::build_nucleo(config),
129 selected: None,
130 item_count: 0,
131 matched_item_count: 0,
132 index: 0,
133 input: Input::default(),
134 height: 0,
135 top: 0,
136 kind,
137 }
138 }
139
140 fn for_lines() -> Self {
141 Self::build(Config::DEFAULT, FuzzyKind::Line)
142 }
143
144 fn for_help() -> Self {
145 Self::build(Config::DEFAULT, FuzzyKind::Action)
146 }
147
148 pub fn set_height(mut self, height: usize) -> Self {
151 self.height = height as u32;
152 self
153 }
154
155 pub fn should_preview(&self) -> bool {
158 matches!(self.kind, FuzzyKind::File)
159 }
160
161 pub fn injector(&self) -> Injector<String> {
163 self.matcher.injector()
164 }
165
166 pub fn update_input(&mut self, append: bool) {
169 self.matcher.pattern.reparse(
170 0,
171 &self.input.string(),
172 pattern::CaseMatching::Smart,
173 pattern::Normalization::Smart,
174 append,
175 )
176 }
177
178 fn index_clamped(&self, matched_item_count: u32) -> u32 {
179 if matched_item_count == 0 {
180 0
181 } else {
182 min(self.index, matched_item_count.saturating_sub(1))
183 }
184 }
185
186 pub fn tick(&mut self, force: bool) {
189 if self.matcher.tick(10).changed || force {
190 self.tick_forced()
191 }
192 }
193
194 fn tick_forced(&mut self) {
198 let snapshot = self.matcher.snapshot();
199 self.item_count = snapshot.item_count();
200 self.matched_item_count = snapshot.matched_item_count();
201 self.index = self.index_clamped(self.matched_item_count);
202 if let Some(item) = snapshot.get_matched_item(self.index) {
203 self.selected = Some(format_display(&item.matcher_columns[0]).to_owned());
204 };
205 self.update_top();
206 }
207
208 fn update_top(&mut self) {
209 let (top, _botom) = self.top_bottom();
210 self.top = top;
211 }
212
213 pub fn top_bottom(&self) -> (u32, u32) {
230 let used_height = self
231 .height
232 .saturating_sub(ContentWindow::WINDOW_PADDING_FUZZY);
233
234 let mut top = self.top;
235 if self.index <= top {
236 top = self.index;
238 }
239
240 if self.matched_item_count < used_height {
241 (0, self.matched_item_count)
243 } else if self.index
244 > (top + used_height).saturating_add(ContentWindow::WINDOW_PADDING_FUZZY)
245 {
246 let bottom = max(top + used_height, self.matched_item_count);
248 (bottom.saturating_sub(used_height) + 1, bottom)
249 } else if self.index < top + ContentWindow::WINDOW_PADDING_FUZZY {
250 if top + used_height > self.matched_item_count {
252 top = self.matched_item_count.saturating_sub(used_height);
253 }
254 (
255 top.saturating_sub(1),
256 min(top + used_height, self.matched_item_count),
257 )
258 } else if self.index + ContentWindow::WINDOW_PADDING_FUZZY > top + used_height {
259 (top + 1, min(top + used_height + 1, self.matched_item_count))
261 } else {
262 (top, min(top + used_height, self.matched_item_count))
264 }
265 }
266
267 pub fn resize(&mut self, height: usize) {
269 self.height = height as u32;
270 self.tick(true);
271 }
272
273 pub fn pick(&self) -> Option<std::string::String> {
276 #[cfg(debug_assertions)]
277 self.log();
278 self.selected.to_owned()
279 }
280
281 #[cfg(debug_assertions)]
283 fn log(&self) {
284 crate::log_info!(
285 "index {idx} top {top} offset {off} - top_bot {top_bot:?} - matched {mic} - items {itc} - height {hei}",
286 idx = self.index,
287 top = self.top,
288 off = self.index.saturating_sub(self.top),
289 top_bot = self.top_bottom(),
290 mic = self.matched_item_count,
291 itc = self.item_count,
292 hei = self.height,
293 );
294 }
295}
296
297impl FuzzyFinder<String> {
298 fn select_next(&mut self) {
299 self.index += 1;
300 }
301
302 fn select_prev(&mut self) {
303 self.index = self.index.saturating_sub(1);
304 }
305
306 fn select_clic(&mut self, row: u16) {
307 let row = row as u32;
308 if row <= ContentWindow::WINDOW_PADDING_FUZZY || row > self.height {
309 return;
310 }
311 self.index = self.top + row - (ContentWindow::WINDOW_PADDING_FUZZY) - 1;
312 }
313
314 fn select_start(&mut self) {
315 self.index = 0;
316 }
317
318 fn select_end(&mut self) {
319 self.index = u32::MAX;
320 }
321
322 fn page_up(&mut self) {
323 for _ in 0..10 {
324 if self.index == 0 {
325 break;
326 }
327 self.select_prev()
328 }
329 }
330
331 fn page_down(&mut self) {
332 for _ in 0..10 {
333 self.select_next()
334 }
335 }
336
337 pub fn navigate(&mut self, direction: Direction) {
338 match direction {
339 Direction::Up => self.select_prev(),
340 Direction::Down => self.select_next(),
341 Direction::PageUp => self.page_up(),
342 Direction::PageDown => self.page_down(),
343 Direction::Index(index) => self.select_clic(index),
344 Direction::Start => self.select_start(),
345 Direction::End => self.select_end(),
346 }
347 self.tick(true);
348 #[cfg(debug_assertions)]
349 self.log();
350 }
351
352 pub fn find_files(&self, current_path: PathBuf) {
353 let injector = self.injector();
354 spawn(move || {
355 for entry in WalkDir::new(current_path)
356 .into_iter()
357 .filter_map(Result::ok)
358 {
359 let value = entry.path().display().to_string();
360 let _ = injector.push(value, |value, cols| {
361 cols[0] = value.as_str().into();
362 });
363 }
364 });
365 }
366
367 pub fn find_action(&self, help: String) {
368 let injector = self.injector();
369 spawn(move || {
370 for line in help.lines() {
371 injector.push_line(line);
372 }
373 });
374 }
375
376 pub fn find_line(&self, tokio_greper: TokioCommand) {
377 let injector = self.injector();
378 spawn(move || {
379 inject_command(tokio_greper, injector);
380 });
381 }
382}
383
384pub fn parse_line_output(item: &str) -> Result<PathBuf> {
386 Ok(canonicalize(PathBuf::from(
387 item.split_once(':').unwrap_or(("", "")).0.to_owned(),
388 ))?)
389}
390
391trait PushLine {
392 fn push_line(&self, line: &str);
393}
394
395impl PushLine for Injector<String> {
396 fn push_line(&self, line: &str) {
397 let _ = self.push(line.to_owned(), |line, cols| {
398 cols[0] = line.as_str().into();
399 });
400 }
401}
402
403fn format_display(display: &Utf32String) -> String {
408 display
409 .slice(..)
410 .chars()
411 .filter(|ch| !ch.is_control())
412 .map(|ch| match ch {
413 '\n' => ' ',
414 s => s,
415 })
416 .collect::<String>()
417}
418
419pub fn highlighted_text<'a>(
421 text: &'a str,
422 highlighted: &[usize],
423 is_selected: bool,
424 is_file: bool,
425) -> Line<'a> {
426 let mut spans = create_spans(is_selected);
427 if is_file && with_icon() || with_icon_metadata() {
428 push_icon(text, is_selected, &mut spans);
429 }
430 let mut curr_segment = String::new();
431 let mut highlight_indices = highlighted.iter().copied().peekable();
432 let mut next_highlight = highlight_indices.next();
433
434 for (index, grapheme) in text.graphemes(true).enumerate() {
435 if Some(index) == next_highlight {
436 if !curr_segment.is_empty() {
437 push_clear(&mut spans, &mut curr_segment, is_selected, false);
438 }
439 curr_segment.push_str(grapheme);
440 push_clear(&mut spans, &mut curr_segment, is_selected, true);
441 next_highlight = highlight_indices.next();
442 } else {
443 curr_segment.push_str(grapheme);
444 }
445 }
446
447 if !curr_segment.is_empty() {
448 spans.push(create_span(curr_segment, is_selected, false));
449 }
450
451 Line::from(spans)
452}
453
454fn push_icon(text: &str, is_selected: bool, spans: &mut Vec<Span>) {
455 let file_path = std::path::Path::new(&text);
456 let Ok(meta) = file_path.symlink_metadata() else {
457 return;
458 };
459 let file_kind = FileKind::new(&meta, file_path);
460 let file_icon = match file_kind {
461 FileKind::NormalFile => extract_extension(file_path).icon(),
462 file_kind => file_kind.icon(),
463 };
464 let index = if is_selected { 2 } else { 0 };
465 spans.push(Span::styled(file_icon, ARRAY_STYLES[index]))
466}
467
468fn push_clear(
469 spans: &mut Vec<Span>,
470 curr_segment: &mut String,
471 is_selected: bool,
472 is_highlighted: bool,
473) {
474 spans.push(create_span(
475 curr_segment.clone(),
476 is_selected,
477 is_highlighted,
478 ));
479 curr_segment.clear();
480}
481
482static DEFAULT_STYLE: Style = Style {
483 fg: Some(Color::Gray),
484 bg: None,
485 add_modifier: Modifier::empty(),
486 underline_color: None,
487 sub_modifier: Modifier::empty(),
488};
489
490static SELECTED: Style = Style {
491 fg: Some(Color::Black),
492 bg: Some(Color::Cyan),
493 add_modifier: Modifier::BOLD,
494 underline_color: None,
495 sub_modifier: Modifier::empty(),
496};
497
498static HIGHLIGHTED: Style = Style {
499 fg: Some(Color::White),
500 bg: None,
501 add_modifier: Modifier::BOLD,
502 underline_color: None,
503 sub_modifier: Modifier::empty(),
504};
505
506static HIGHLIGHTED_SELECTED: Style = Style {
507 fg: Some(Color::White),
508 bg: Some(Color::Cyan),
509 add_modifier: Modifier::BOLD,
510 underline_color: None,
511 sub_modifier: Modifier::empty(),
512};
513
514static ARRAY_STYLES: [Style; 4] = [DEFAULT_STYLE, HIGHLIGHTED, SELECTED, HIGHLIGHTED_SELECTED];
516
517static SPACER_DEFAULT: &str = " ";
518static SPACER_SELECTED: &str = "> ";
519
520fn create_spans(is_selected: bool) -> Vec<Span<'static>> {
521 vec![if is_selected {
522 Span::styled(SPACER_SELECTED, SELECTED)
523 } else {
524 Span::styled(SPACER_DEFAULT, DEFAULT_STYLE)
525 }]
526}
527
528fn choose_style(is_selected: bool, is_highlighted: bool) -> Style {
529 let index = ((is_selected as usize) << 1) + is_highlighted as usize;
530 ARRAY_STYLES[index]
531}
532
533fn create_span<'a>(curr_segment: String, is_selected: bool, is_highlighted: bool) -> Span<'a> {
534 Span::styled(curr_segment, choose_style(is_selected, is_highlighted))
535}