cocotte 0.1.1

A convenient way to make a Ratatui
Documentation
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()
    }
}