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)
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> {
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
403/// Format a [`Utf32String`] for displaying. Currently:
404/// - Delete control characters.
405/// - Truncates the string to an appropriate length.
406/// - Replaces any newline characters with spaces.
407fn 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
419/// Build a [`ratatui::text::Line`] for a given fuzzy output line.
420pub 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
514/// Order is important, item are retrieved by calculating (is_selected)<<1 + (is_highlighted).
515static 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}