fm/modes/display/
nucleo_picker.rs

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
26/// Directions for nucleo picker navigation.
27/// Usefull to avoid spreading too much the manipulation
28/// in status.
29pub enum Direction {
30    Up,
31    Down,
32    PageUp,
33    PageDown,
34    Start,
35    End,
36    Index(u16),
37}
38
39/// What kind of content is beeing matched ?
40/// File: we match against paths,
41/// Line & Action we match against strings but actions differ.
42pub 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
54/// The fuzzy picker of file.
55/// it may be in one of 3 kinds:
56/// - for file, it will match against paths from current folder,
57/// - for lines, it will match against any text of a text files from current folder,
58/// - for actions, it will match against any text from help, allowing to run an action when you forgot the keybind.
59///
60/// Internally, it's just :
61/// - a [`Nucleo`] matcher,
62/// - a few `u32`: index, top (first displayed index), height of the window, item count, matched item count
63/// - and the current selection as a string.
64///
65/// The matcher is used externally by display to get the displayed matches and internally to update
66/// the selection when the user type something or move around.
67///
68/// The interface shouldn't change much, except to add more shortcut.
69pub struct FuzzyFinder<String: Sync + Send + 'static> {
70    /// kind of fuzzy:
71    /// Line (match lines into text file),
72    /// File (match file against their name),
73    /// Action (match an action)
74    pub kind: FuzzyKind,
75    /// The fuzzy matcher
76    pub matcher: Nucleo<String>,
77    /// matched string
78    selected: Option<std::string::String>,
79    /// typed input by the user
80    pub input: Input,
81    /// number of parsed item
82    pub item_count: u32,
83    /// number of matched item
84    pub matched_item_count: u32,
85    /// selected index. Should always been smaller than matched_item_count
86    pub index: u32,
87    /// index of the top displayed element in the matcher
88    top: u32,
89    /// height of the terminal window, header & footer included
90    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    /// Creates a new fuzzy matcher for this kind.
118    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    /// Set the terminal height of the fuzzy picker.
149    /// It should always be called after new
150    pub fn set_height(mut self, height: usize) -> Self {
151        self.height = height as u32;
152        self
153    }
154
155    /// True iff a preview should be built for this fuzzy finder.
156    /// It only makes sense to preview files not lines nor actions.
157    pub fn should_preview(&self) -> bool {
158        matches!(self.kind, FuzzyKind::File | FuzzyKind::Line)
159    }
160
161    /// Get an [`Injector`] from the internal [`Nucleo`] instance.
162    pub fn injector(&self) -> Injector<String> {
163        self.matcher.injector()
164    }
165
166    /// if insert char: append = true,
167    /// if delete char: append = false,
168    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    /// tick the matcher.
187    /// refresh the selection if the status changed or if force = true.
188    pub fn tick(&mut self, force: bool) {
189        if self.matcher.tick(10).changed || force {
190            self.tick_forced()
191        }
192    }
193
194    /// Refresh the content, storing selection, number of items, matched items and updating the top index.
195    /// We need to store here the "top" since display can't update fuzzy (it receives a non mutable ref.).
196    /// Scrolling is impossible without the update of top once the new index is got.
197    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    /// Calculate the first & last matching index which should be stored in content.
214    /// It assumes the index can't change by more than one at a time.
215    /// Returning both values (top & bottom) allows to avoid mutating self here.
216    /// This method can be called in [`crate::io::Display`] to know what matches should be drawn.
217    ///
218    /// It should only be called after a refresh of the matcher to be sure
219    /// the matched_item_count is correct.
220    ///
221    /// Several cases :
222    /// - if there's not enough element to fill the display, take everything.
223    /// - if the selection is in the top 4 rows, scroll up if possible.
224    /// - if the selection is in the last 4 rows, scroll down if possible.
225    /// - otherwise, don't move.
226    ///
227    /// Scrolling is done only at top or bottom, not in the middle of the screen.
228    /// It feels more natural.
229    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            // Window is too low
237            top = self.index;
238        }
239
240        if self.matched_item_count < used_height {
241            // not enough items to fill the display, take everything
242            (0, self.matched_item_count)
243        } else if self.index
244            > (top + used_height).saturating_add(ContentWindow::WINDOW_PADDING_FUZZY)
245        {
246            // window is too high
247            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            // scroll up by one
251            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            // scroll down by one
260            (top + 1, min(top + used_height + 1, self.matched_item_count))
261        } else {
262            // don't move
263            (top, min(top + used_height, self.matched_item_count))
264        }
265    }
266
267    /// Set the new height and refresh the content.
268    pub fn resize(&mut self, height: usize) {
269        self.height = height as u32;
270        self.tick(true);
271    }
272
273    /// Returns the selected element, if its index is valid.
274    /// It should never return `None` if the content isn't empty.
275    pub fn pick(&self) -> Option<std::string::String> {
276        #[cfg(debug_assertions)]
277        self.log();
278        self.selected.to_owned()
279    }
280
281    // Do not erase, used for debugging purpose
282    #[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
384/// Parse a line output from the fuzzy finder.
385pub fn parse_line_output(item: &str) -> Result<(PathBuf, Option<usize>)> {
386    let mut split = item.split(':');
387    let path = split.next().unwrap_or_default();
388    let line_index = split.next().map(|s| s.parse().unwrap_or_default());
389    Ok((canonicalize(PathBuf::from(path))?, line_index))
390}
391
392trait PushLine {
393    fn push_line(&self, line: &str);
394}
395
396impl PushLine for Injector<String> {
397    fn push_line(&self, line: &str) {
398        let _ = self.push(line.to_owned(), |line, cols| {
399            cols[0] = line.as_str().into();
400        });
401    }
402}
403
404/// Format a [`Utf32String`] for displaying. Currently:
405/// - Delete control characters.
406/// - Truncates the string to an appropriate length.
407/// - Replaces any newline characters with spaces.
408fn format_display(display: &Utf32String) -> String {
409    display
410        .slice(..)
411        .chars()
412        .filter(|ch| !ch.is_control())
413        .map(|ch| match ch {
414            '\n' => ' ',
415            s => s,
416        })
417        .collect::<String>()
418}
419
420/// Build a [`ratatui::text::Line`] for a given fuzzy output line.
421pub fn highlighted_text<'a>(
422    text: &'a str,
423    highlighted: &[usize],
424    is_selected: bool,
425    is_file: bool,
426    is_flagged: bool,
427) -> Line<'a> {
428    let mut spans = create_spans(is_selected, is_flagged);
429    if is_file && with_icon() || with_icon_metadata() {
430        push_icon(text, is_selected, is_flagged, &mut spans);
431    }
432    let mut curr_segment = String::new();
433    let mut highlight_indices = highlighted.iter().copied().peekable();
434    let mut next_highlight = highlight_indices.next();
435
436    for (index, grapheme) in text.graphemes(true).enumerate() {
437        if Some(index) == next_highlight {
438            if !curr_segment.is_empty() {
439                push_clear(
440                    &mut spans,
441                    &mut curr_segment,
442                    is_selected,
443                    false,
444                    is_flagged,
445                );
446            }
447            curr_segment.push_str(grapheme);
448            push_clear(&mut spans, &mut curr_segment, is_selected, true, is_flagged);
449            next_highlight = highlight_indices.next();
450        } else {
451            curr_segment.push_str(grapheme);
452        }
453    }
454
455    if !curr_segment.is_empty() {
456        spans.push(create_span(curr_segment, is_selected, false, is_flagged));
457    }
458
459    Line::from(spans)
460}
461
462fn push_icon(text: &str, is_selected: bool, is_flagged: bool, spans: &mut Vec<Span>) {
463    let file_path = std::path::Path::new(&text);
464    let Ok(meta) = file_path.symlink_metadata() else {
465        return;
466    };
467    let file_kind = FileKind::new(&meta, file_path);
468    let file_icon = match file_kind {
469        FileKind::NormalFile => extract_extension(file_path).icon(),
470        file_kind => file_kind.icon(),
471    };
472    let mut index = if is_selected { 2 } else { 0 };
473    if is_flagged {
474        index += 4;
475    }
476    spans.push(Span::styled(file_icon, ARRAY_STYLES[index]))
477}
478
479fn push_clear(
480    spans: &mut Vec<Span>,
481    curr_segment: &mut String,
482    is_selected: bool,
483    is_highlighted: bool,
484    is_flagged: bool,
485) {
486    spans.push(create_span(
487        curr_segment.clone(),
488        is_selected,
489        is_highlighted,
490        is_flagged,
491    ));
492    curr_segment.clear();
493}
494
495static DEFAULT_STYLE: Style = Style {
496    fg: Some(Color::Gray),
497    bg: None,
498    add_modifier: Modifier::empty(),
499    underline_color: None,
500    sub_modifier: Modifier::empty(),
501};
502
503static SELECTED: Style = Style {
504    fg: Some(Color::Black),
505    bg: Some(Color::Cyan),
506    add_modifier: Modifier::BOLD,
507    underline_color: None,
508    sub_modifier: Modifier::empty(),
509};
510
511static HIGHLIGHTED: Style = Style {
512    fg: Some(Color::White),
513    bg: None,
514    add_modifier: Modifier::BOLD,
515    underline_color: None,
516    sub_modifier: Modifier::empty(),
517};
518
519static HIGHLIGHTED_SELECTED: Style = Style {
520    fg: Some(Color::White),
521    bg: Some(Color::Cyan),
522    add_modifier: Modifier::BOLD,
523    underline_color: None,
524    sub_modifier: Modifier::empty(),
525};
526
527static DEFAULT_STYLE_FLAGGED: Style = Style {
528    fg: Some(Color::Yellow),
529    bg: None,
530    add_modifier: Modifier::empty(),
531    underline_color: None,
532    sub_modifier: Modifier::empty(),
533};
534
535static SELECTED_FLAGGED: Style = Style {
536    fg: Some(Color::Black),
537    bg: Some(Color::Yellow),
538    add_modifier: Modifier::BOLD,
539    underline_color: None,
540    sub_modifier: Modifier::empty(),
541};
542
543static HIGHLIGHTED_FLAGGED: Style = Style {
544    fg: Some(Color::Yellow),
545    bg: None,
546    add_modifier: Modifier::BOLD,
547    underline_color: None,
548    sub_modifier: Modifier::empty(),
549};
550
551static HIGHLIGHTED_SELECTED_FLAGGED: Style = Style {
552    fg: Some(Color::White),
553    bg: Some(Color::Yellow),
554    add_modifier: Modifier::BOLD,
555    underline_color: None,
556    sub_modifier: Modifier::empty(),
557};
558
559/// Order is important, item are retrieved by calculating (is_selected)<<1 + (is_highlighted).
560static ARRAY_STYLES: [Style; 8] = [
561    DEFAULT_STYLE,
562    HIGHLIGHTED,
563    SELECTED,
564    HIGHLIGHTED_SELECTED,
565    DEFAULT_STYLE_FLAGGED,
566    HIGHLIGHTED_FLAGGED,
567    SELECTED_FLAGGED,
568    HIGHLIGHTED_SELECTED_FLAGGED,
569];
570
571static SPACER_DEFAULT: &str = "  ";
572static SPACER_SELECTED: &str = "> ";
573
574fn create_spans(is_selected: bool, is_flagged: bool) -> Vec<Span<'static>> {
575    let index = ((is_flagged as usize) << 2) + ((is_selected as usize) << 1);
576    let style = ARRAY_STYLES[index];
577    let space = if is_selected {
578        SPACER_SELECTED
579    } else {
580        SPACER_DEFAULT
581    };
582    vec![Span::styled(space, style)]
583}
584
585fn choose_style(is_selected: bool, is_highlighted: bool, is_flagged: bool) -> Style {
586    let index =
587        ((is_flagged as usize) << 2) + ((is_selected as usize) << 1) + is_highlighted as usize;
588    ARRAY_STYLES[index]
589}
590
591fn create_span<'a>(
592    curr_segment: String,
593    is_selected: bool,
594    is_highlighted: bool,
595    is_flagged: bool,
596) -> Span<'a> {
597    Span::styled(
598        curr_segment,
599        choose_style(is_selected, is_highlighted, is_flagged),
600    )
601}