use crate::AppState;
use crate::EventEnum;
use crate::Focus;
use cocotte::SubApp;
use cocotte::ratatui::Frame;
use cocotte::ratatui::layout::Constraint;
use cocotte::ratatui::layout::Rect;
use cocotte::ratatui::style::{Color, Modifier, Style};
use cocotte::ratatui::text::{Line, Span};
use cocotte::ratatui::widgets::{Block, Borders, List, ListState};
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use std::fs;
use std::iter::once;
use std::path::{Path, PathBuf};
use sublime_fuzzy::{FuzzySearch, Scoring};
fn string_to_styled_spans(s: &str, highlighted: &[usize]) -> Vec<Span<'static>> {
let chars: Vec<char> = s.chars().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut normal = String::new();
for (i, &ch) in chars.iter().enumerate() {
if highlighted.contains(&i) {
if !normal.is_empty() {
spans.push(Span::raw(std::mem::take(&mut normal)));
}
spans.push(Span::styled(
ch.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
} else {
normal.push(ch);
}
}
if !normal.is_empty() {
spans.push(Span::raw(normal));
}
spans
}
pub struct Browser {
pub scoring: Scoring,
pub working_directory: PathBuf,
pub line_index: usize,
pub directories: Vec<PathBuf>,
pub filtered_directory_indices: Vec<usize>,
pub displayed_directories: Vec<Line<'static>>,
pub files: Vec<PathBuf>,
pub filtered_file_indices: Vec<usize>,
pub displayed_files: Vec<Line<'static>>,
}
fn get_entries(dir: &Path) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut directories = Vec::new();
let mut files = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.filter_map(Result::ok) {
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_dir() {
directories.push(path);
} else if file_type.is_file() {
files.push(path);
}
}
}
(directories, files)
}
impl Browser {
pub fn new() -> Browser {
let scoring = Scoring {
bonus_consecutive: 64,
bonus_word_start: 1,
bonus_match_case: 8,
penalty_distance: 16,
};
Browser {
scoring,
working_directory: PathBuf::default(),
line_index: 0,
directories: vec![],
filtered_directory_indices: vec![],
displayed_directories: vec![],
files: vec![],
filtered_file_indices: vec![],
displayed_files: vec![],
}
}
fn refresh_entries(&mut self, dir: &Path) {
self.working_directory = dir.to_path_buf();
let (directories, files) = get_entries(dir);
self.directories = directories;
self.files = files;
}
fn directory_label(path: &Path) -> String {
path.display().to_string()
}
fn file_label(path: &Path) -> String {
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
}
fn match_indices(&self, label: &str, filter: &str) -> Option<Vec<usize>> {
if filter.is_empty() {
return Some(Vec::new());
}
FuzzySearch::new(filter, label)
.case_insensitive()
.score_with(&self.scoring)
.best_match()
.filter(|matched| matched.score() > 0)
.map(|matched| matched.matched_indices().copied().collect())
}
fn rebuild_directory_view(&mut self, filter: &str) {
let mut filtered_directory_indices = Vec::new();
let mut displayed_directories = Vec::new();
for (index, path) in self.directories.iter().enumerate() {
let label = Self::directory_label(path);
if let Some(indices) = self.match_indices(&label, filter) {
filtered_directory_indices.push(index);
displayed_directories.push(Line::from(string_to_styled_spans(&label, &indices)));
}
}
self.filtered_directory_indices = filtered_directory_indices;
self.displayed_directories = displayed_directories;
self.line_index = self
.line_index
.min(self.displayed_directories.len().saturating_sub(1));
}
fn rebuild_file_view(&mut self, filter: &str) {
let mut filtered_file_indices = Vec::new();
let mut displayed_files = Vec::new();
for (index, path) in self.files.iter().enumerate() {
let label = Self::file_label(path);
if let Some(indices) = self.match_indices(&label, filter) {
filtered_file_indices.push(index);
displayed_files.push(Line::from(string_to_styled_spans(&label, &indices)));
}
}
self.filtered_file_indices = filtered_file_indices;
self.displayed_files = displayed_files;
self.line_index = self
.line_index
.min(self.displayed_files.len().saturating_sub(1));
}
fn visible_len(&self, focus: &Focus) -> usize {
match focus {
Focus::Directories => self.filtered_directory_indices.len(),
Focus::Files => self.filtered_file_indices.len(),
}
}
fn move_up(&mut self) {
if self.line_index > 0 {
self.line_index -= 1;
}
}
fn move_down(&mut self, focus: &Focus) {
if self.line_index + 1 < self.visible_len(focus) {
self.line_index += 1;
}
}
}
impl SubApp<EventEnum, AppState> for Browser {
fn handle_input(&mut self, event: &mut EventEnum, app_state: &mut AppState) {
match event {
EventEnum::Setup => {
self.refresh_entries(&app_state.current_directory);
self.rebuild_directory_view(&app_state.directory_filter);
self.rebuild_file_view(&app_state.file_filter);
}
EventEnum::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => {
app_state.focus = Focus::Directories;
}
EventEnum::Key(KeyEvent {
code: KeyCode::Up, ..
}) => {
self.move_up();
}
EventEnum::Key(KeyEvent {
code: KeyCode::Down,
..
}) => {
self.move_down(&app_state.focus);
}
EventEnum::Key(KeyEvent {
code: KeyCode::Enter,
..
}) if app_state.focus == Focus::Directories => {
self.rebuild_file_view(&app_state.file_filter);
app_state.focus = Focus::Files;
}
EventEnum::Key(KeyEvent {
code: KeyCode::Tab, ..
}) if app_state.focus == Focus::Directories => {
let Some(&index) = self.filtered_directory_indices.get(self.line_index) else {
return;
};
app_state.current_directory = self.directories[index].clone();
self.refresh_entries(&app_state.current_directory);
self.rebuild_directory_view(&app_state.directory_filter);
}
EventEnum::Key(KeyEvent {
code: KeyCode::Backspace,
..
}) => match app_state.focus {
Focus::Directories => {
if app_state.directory_filter.is_empty() {
if let Some(parent) = app_state.current_directory.parent() {
app_state.current_directory = parent.to_path_buf();
}
} else {
app_state.directory_filter.pop();
}
self.refresh_entries(&app_state.current_directory);
self.rebuild_directory_view(&app_state.directory_filter);
}
Focus::Files => {
if !app_state.file_filter.is_empty() {
app_state.file_filter.pop();
self.rebuild_file_view(&app_state.file_filter);
} else {
if let Some(parent) = app_state.current_directory.parent() {
app_state.current_directory = parent.to_path_buf();
}
self.refresh_entries(&app_state.current_directory);
self.rebuild_directory_view(&app_state.directory_filter);
app_state.focus = Focus::Directories;
}
}
},
EventEnum::Key(KeyEvent {
code: KeyCode::Char(c),
..
}) => match app_state.focus {
Focus::Directories => {
app_state.directory_filter.push(*c);
self.rebuild_directory_view(&app_state.directory_filter);
self.line_index = 0;
}
Focus::Files => {
app_state.file_filter.push(*c);
self.rebuild_file_view(&app_state.file_filter);
self.line_index = 0;
}
},
_ => {}
}
}
fn render(&self, frame: &mut Frame, area: Rect, app_state: &mut AppState) {
let title = match app_state.focus {
Focus::Directories => "Directories",
Focus::Files => "Files",
};
let block = Block::default().borders(Borders::ALL).title(title);
let list = match app_state.focus {
Focus::Directories => {
if self.displayed_directories.is_empty() {
List::new(once(Line::from("(no directories)")))
} else {
List::new(self.displayed_directories.iter().cloned())
}
}
Focus::Files => {
if self.displayed_files.is_empty() {
List::new(once(Line::from("(no files)")))
} else {
List::new(self.displayed_files.iter().cloned())
}
}
}
.block(block)
.highlight_style(
Style::default()
.add_modifier(Modifier::REVERSED)
.add_modifier(Modifier::BOLD),
);
let mut state = ListState::default();
let visible_len = self.visible_len(&app_state.focus);
if visible_len != 0 {
state.select(Some(self.line_index.min(visible_len - 1)));
}
frame.render_stateful_widget(list, area, &mut state);
}
fn constraints(&self) -> Constraint {
Constraint::Fill(1)
}
}
impl Default for Browser {
fn default() -> Self {
Self::new()
}
}