use std::{
cmp::{max, min},
fs::canonicalize,
path::PathBuf,
sync::Arc,
thread::{available_parallelism, spawn},
};
use anyhow::Result;
use nucleo::{pattern, Config, Injector, Nucleo, Utf32String};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use tokio::process::Command as TokioCommand;
use unicode_segmentation::UnicodeSegmentation;
use walkdir::WalkDir;
use crate::modes::{extract_extension, ContentWindow, Icon, Input};
use crate::{
config::{with_icon, with_icon_metadata},
io::inject_command,
modes::FileKind,
};
pub enum Direction {
Up,
Down,
PageUp,
PageDown,
Start,
End,
Index(u16),
}
pub enum FuzzyKind {
File,
Line,
Action,
}
impl FuzzyKind {
pub fn is_file(&self) -> bool {
matches!(self, Self::File)
}
}
pub struct FuzzyFinder<String: Sync + Send + 'static> {
pub kind: FuzzyKind,
pub matcher: Nucleo<String>,
selected: Option<std::string::String>,
pub input: Input,
pub item_count: u32,
pub matched_item_count: u32,
pub index: u32,
top: u32,
height: u32,
}
impl<String: Sync + Send + 'static> Default for FuzzyFinder<String>
where
Vec<String>: FromIterator<std::string::String>,
{
fn default() -> Self {
let config = Config::DEFAULT.match_paths();
Self::build(config, FuzzyKind::File)
}
}
impl<String: Sync + Send + 'static> FuzzyFinder<String>
where
Vec<String>: FromIterator<std::string::String>,
{
fn default_thread_count() -> Option<usize> {
available_parallelism()
.map(|it| it.get().checked_sub(2).unwrap_or(1))
.ok()
}
fn build_nucleo(config: Config) -> Nucleo<String> {
Nucleo::new(config, Arc::new(|| {}), Self::default_thread_count(), 1)
}
pub fn new(kind: FuzzyKind) -> Self {
match kind {
FuzzyKind::File => Self::default(),
FuzzyKind::Line => Self::for_lines(),
FuzzyKind::Action => Self::for_help(),
}
}
fn build(config: Config, kind: FuzzyKind) -> Self {
Self {
matcher: Self::build_nucleo(config),
selected: None,
item_count: 0,
matched_item_count: 0,
index: 0,
input: Input::default(),
height: 0,
top: 0,
kind,
}
}
fn for_lines() -> Self {
Self::build(Config::DEFAULT, FuzzyKind::Line)
}
fn for_help() -> Self {
Self::build(Config::DEFAULT, FuzzyKind::Action)
}
pub fn set_height(mut self, height: usize) -> Self {
self.height = height as u32;
self
}
pub fn should_preview(&self) -> bool {
matches!(self.kind, FuzzyKind::File | FuzzyKind::Line)
}
pub fn injector(&self) -> Injector<String> {
self.matcher.injector()
}
pub fn update_input(&mut self, append: bool) {
self.matcher.pattern.reparse(
0,
&self.input.string(),
pattern::CaseMatching::Smart,
pattern::Normalization::Smart,
append,
)
}
fn index_clamped(&self, matched_item_count: u32) -> u32 {
if matched_item_count == 0 {
0
} else {
min(self.index, matched_item_count.saturating_sub(1))
}
}
pub fn tick(&mut self, force: bool) {
if self.matcher.tick(10).changed || force {
self.tick_forced()
}
}
fn tick_forced(&mut self) {
let snapshot = self.matcher.snapshot();
self.item_count = snapshot.item_count();
self.matched_item_count = snapshot.matched_item_count();
self.index = self.index_clamped(self.matched_item_count);
if let Some(item) = snapshot.get_matched_item(self.index) {
self.selected = Some(format_display(&item.matcher_columns[0]).to_owned());
};
self.update_top();
}
fn update_top(&mut self) {
let (top, _botom) = self.top_bottom();
self.top = top;
}
pub fn top_bottom(&self) -> (u32, u32) {
let used_height = self
.height
.saturating_sub(ContentWindow::WINDOW_PADDING_FUZZY);
let mut top = self.top;
if self.index <= top {
top = self.index;
}
if self.matched_item_count < used_height {
(0, self.matched_item_count)
} else if self.index
> (top + used_height).saturating_add(ContentWindow::WINDOW_PADDING_FUZZY)
{
let bottom = max(top + used_height, self.matched_item_count);
(bottom.saturating_sub(used_height) + 1, bottom)
} else if self.index < top + ContentWindow::WINDOW_PADDING_FUZZY {
if top + used_height > self.matched_item_count {
top = self.matched_item_count.saturating_sub(used_height);
}
(
top.saturating_sub(1),
min(top + used_height, self.matched_item_count),
)
} else if self.index + ContentWindow::WINDOW_PADDING_FUZZY > top + used_height {
(top + 1, min(top + used_height + 1, self.matched_item_count))
} else {
(top, min(top + used_height, self.matched_item_count))
}
}
pub fn resize(&mut self, height: usize) {
self.height = height as u32;
self.tick(true);
}
pub fn pick(&self) -> Option<std::string::String> {
#[cfg(debug_assertions)]
self.log();
self.selected.to_owned()
}
#[cfg(debug_assertions)]
fn log(&self) {
crate::log_info!(
"index {idx} top {top} offset {off} - top_bot {top_bot:?} - matched {mic} - items {itc} - height {hei}",
idx = self.index,
top = self.top,
off = self.index.saturating_sub(self.top),
top_bot = self.top_bottom(),
mic = self.matched_item_count,
itc = self.item_count,
hei = self.height,
);
}
}
impl FuzzyFinder<String> {
fn select_next(&mut self) {
self.index += 1;
}
fn select_prev(&mut self) {
self.index = self.index.saturating_sub(1);
}
fn select_clic(&mut self, row: u16) {
let row = row as u32;
if row <= ContentWindow::WINDOW_PADDING_FUZZY || row > self.height {
return;
}
self.index = self.top + row - (ContentWindow::WINDOW_PADDING_FUZZY) - 1;
}
fn select_start(&mut self) {
self.index = 0;
}
fn select_end(&mut self) {
self.index = u32::MAX;
}
fn page_up(&mut self) {
for _ in 0..10 {
if self.index == 0 {
break;
}
self.select_prev()
}
}
fn page_down(&mut self) {
for _ in 0..10 {
self.select_next()
}
}
pub fn navigate(&mut self, direction: Direction) {
match direction {
Direction::Up => self.select_prev(),
Direction::Down => self.select_next(),
Direction::PageUp => self.page_up(),
Direction::PageDown => self.page_down(),
Direction::Index(index) => self.select_clic(index),
Direction::Start => self.select_start(),
Direction::End => self.select_end(),
}
self.tick(true);
#[cfg(debug_assertions)]
self.log();
}
pub fn find_files(&self, current_path: PathBuf) {
let injector = self.injector();
spawn(move || {
for entry in WalkDir::new(current_path)
.into_iter()
.filter_map(Result::ok)
{
let value = entry.path().display().to_string();
let _ = injector.push(value, |value, cols| {
cols[0] = value.as_str().into();
});
}
});
}
pub fn find_action(&self, help: String) {
let injector = self.injector();
spawn(move || {
for line in help.lines() {
injector.push_line(line);
}
});
}
pub fn find_line(&self, tokio_greper: TokioCommand) {
let injector = self.injector();
spawn(move || {
inject_command(tokio_greper, injector);
});
}
}
pub fn parse_line_output(item: &str) -> Result<(PathBuf, Option<usize>)> {
let mut split = item.split(':');
let path = split.next().unwrap_or_default();
let line_index = split.next().map(|s| s.parse().unwrap_or_default());
Ok((canonicalize(PathBuf::from(path))?, line_index))
}
trait PushLine {
fn push_line(&self, line: &str);
}
impl PushLine for Injector<String> {
fn push_line(&self, line: &str) {
let _ = self.push(line.to_owned(), |line, cols| {
cols[0] = line.as_str().into();
});
}
}
fn format_display(display: &Utf32String) -> String {
display
.slice(..)
.chars()
.filter(|ch| !ch.is_control())
.map(|ch| match ch {
'\n' => ' ',
s => s,
})
.collect::<String>()
}
pub fn highlighted_text<'a>(
text: &'a str,
highlighted: &[usize],
is_selected: bool,
is_file: bool,
is_flagged: bool,
) -> Line<'a> {
let mut spans = create_spans(is_selected, is_flagged);
if is_file && with_icon() || with_icon_metadata() {
push_icon(text, is_selected, is_flagged, &mut spans);
}
let mut curr_segment = String::new();
let mut highlight_indices = highlighted.iter().copied().peekable();
let mut next_highlight = highlight_indices.next();
for (index, grapheme) in text.graphemes(true).enumerate() {
if Some(index) == next_highlight {
if !curr_segment.is_empty() {
push_clear(
&mut spans,
&mut curr_segment,
is_selected,
false,
is_flagged,
);
}
curr_segment.push_str(grapheme);
push_clear(&mut spans, &mut curr_segment, is_selected, true, is_flagged);
next_highlight = highlight_indices.next();
} else {
curr_segment.push_str(grapheme);
}
}
if !curr_segment.is_empty() {
spans.push(create_span(curr_segment, is_selected, false, is_flagged));
}
Line::from(spans)
}
fn push_icon(text: &str, is_selected: bool, is_flagged: bool, spans: &mut Vec<Span>) {
let file_path = std::path::Path::new(&text);
let Ok(meta) = file_path.symlink_metadata() else {
return;
};
let file_kind = FileKind::new(&meta, file_path);
let file_icon = match file_kind {
FileKind::NormalFile => extract_extension(file_path).icon(),
file_kind => file_kind.icon(),
};
let mut index = if is_selected { 2 } else { 0 };
if is_flagged {
index += 4;
}
spans.push(Span::styled(file_icon, ARRAY_STYLES[index]))
}
fn push_clear(
spans: &mut Vec<Span>,
curr_segment: &mut String,
is_selected: bool,
is_highlighted: bool,
is_flagged: bool,
) {
spans.push(create_span(
curr_segment.clone(),
is_selected,
is_highlighted,
is_flagged,
));
curr_segment.clear();
}
static DEFAULT_STYLE: Style = Style {
fg: Some(Color::Gray),
bg: None,
add_modifier: Modifier::empty(),
underline_color: None,
sub_modifier: Modifier::empty(),
};
static SELECTED: Style = Style {
fg: Some(Color::Black),
bg: Some(Color::Cyan),
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static HIGHLIGHTED: Style = Style {
fg: Some(Color::White),
bg: None,
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static HIGHLIGHTED_SELECTED: Style = Style {
fg: Some(Color::White),
bg: Some(Color::Cyan),
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static DEFAULT_STYLE_FLAGGED: Style = Style {
fg: Some(Color::Yellow),
bg: None,
add_modifier: Modifier::empty(),
underline_color: None,
sub_modifier: Modifier::empty(),
};
static SELECTED_FLAGGED: Style = Style {
fg: Some(Color::Black),
bg: Some(Color::Yellow),
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static HIGHLIGHTED_FLAGGED: Style = Style {
fg: Some(Color::Yellow),
bg: None,
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static HIGHLIGHTED_SELECTED_FLAGGED: Style = Style {
fg: Some(Color::White),
bg: Some(Color::Yellow),
add_modifier: Modifier::BOLD,
underline_color: None,
sub_modifier: Modifier::empty(),
};
static ARRAY_STYLES: [Style; 8] = [
DEFAULT_STYLE,
HIGHLIGHTED,
SELECTED,
HIGHLIGHTED_SELECTED,
DEFAULT_STYLE_FLAGGED,
HIGHLIGHTED_FLAGGED,
SELECTED_FLAGGED,
HIGHLIGHTED_SELECTED_FLAGGED,
];
static SPACER_DEFAULT: &str = " ";
static SPACER_SELECTED: &str = "> ";
fn create_spans(is_selected: bool, is_flagged: bool) -> Vec<Span<'static>> {
let index = ((is_flagged as usize) << 2) + ((is_selected as usize) << 1);
let style = ARRAY_STYLES[index];
let space = if is_selected {
SPACER_SELECTED
} else {
SPACER_DEFAULT
};
vec![Span::styled(space, style)]
}
fn choose_style(is_selected: bool, is_highlighted: bool, is_flagged: bool) -> Style {
let index =
((is_flagged as usize) << 2) + ((is_selected as usize) << 1) + is_highlighted as usize;
ARRAY_STYLES[index]
}
fn create_span<'a>(
curr_segment: String,
is_selected: bool,
is_highlighted: bool,
is_flagged: bool,
) -> Span<'a> {
Span::styled(
curr_segment,
choose_style(is_selected, is_highlighted, is_flagged),
)
}