tabiew 0.14.0

A lightweight TUI application to view and query tabular data files, such as CSV, TSV, and parquet.
use std::{
    cmp::Ordering,
    ffi::OsStr,
    fs::{DirEntry, read_dir},
    path::{MAIN_SEPARATOR, Path, PathBuf},
};

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use home::home_dir;
use itertools::Itertools;

use crate::tui::{
    component::Component,
    pickers::text_picker_with_suggestion::{self, TextPickerWithSuggestion},
    widgets::input::Input,
};

#[derive(Debug)]
pub struct FilePicker {
    text_picker: TextPickerWithSuggestion<fn(&str, usize) -> Vec<FileSuggestion>>,
}

impl FilePicker {
    pub fn with_title(self, title: impl Into<String>) -> Self {
        Self {
            text_picker: self.text_picker.with_title(title),
        }
    }

    pub fn path(&self) -> PathBuf {
        self.text_picker.value().into()
    }
}

impl Component for FilePicker {
    fn render(
        &mut self,
        area: ratatui::prelude::Rect,
        buf: &mut ratatui::prelude::Buffer,
        focus_state: crate::tui::component::FocusState,
    ) {
        self.text_picker.render(area, buf, focus_state);
    }

    fn handle(&mut self, event: KeyEvent) -> bool {
        self.text_picker.handle(event)
            || match (event.code, event.modifiers) {
                (KeyCode::Enter, KeyModifiers::NONE) => {
                    self.text_picker.apply_selected_suggestion();
                    self.text_picker.clear_suggestion_selection();
                    self.text_picker
                        .selected_suggestion()
                        .map(|sug| sug.is_dir())
                        .unwrap_or_default()
                }
                (KeyCode::Tab, KeyModifiers::NONE) => {
                    self.text_picker.apply_selected_suggestion();
                    true
                }
                _ => false,
            }
    }
}

impl Default for FilePicker {
    fn default() -> Self {
        Self {
            text_picker: TextPickerWithSuggestion::new(
                "File Path",
                suggestions as for<'a> fn(&'a str, usize) -> Vec<FileSuggestion>,
            )
            .with_value(
                std::env::current_dir()
                    .ok()
                    .or(home_dir())
                    .map(|p| path_to_string(&p))
                    .unwrap_or_default(),
            ),
        }
    }
}

#[derive(Debug)]
struct FileSuggestion {
    title: String,
    path: PathBuf,
}

impl FileSuggestion {
    fn is_dir(&self) -> bool {
        self.path.is_dir()
    }
}

impl text_picker_with_suggestion::Suggestion for FileSuggestion {
    fn title(&self) -> &str {
        &self.title
    }

    fn apply_to(&self, input: &mut Input) {
        input.set_value(path_to_string(&self.path));
    }
}

fn suggestions(query: &str, _: usize) -> Vec<FileSuggestion> {
    let path = Path::new(query);
    let filter = query.split('/').next_back().unwrap_or_default();
    let path = if path.is_dir() && query.ends_with('/') {
        path
    } else if let Some(parent) = path.parent() {
        parent
    } else {
        return Default::default();
    };
    let Ok(read_dir) = read_dir(path) else {
        return Default::default();
    };
    read_dir
        .flat_map(|r| r.ok())
        .filter(|entry| {
            entry
                .file_name()
                .to_string_lossy()
                .to_lowercase()
                .starts_with(&filter.to_lowercase())
        })
        .sorted_by(cmp_dir_entry)
        .flat_map(|entry| {
            let path = entry.path();
            path.file_name()
                .map(OsStr::to_string_lossy)
                .map(|s| {
                    let icon = if path.is_dir() { "[D]" } else { "[F]" };
                    format!("{} {}", icon, s)
                })
                .map(|title| FileSuggestion { title, path })
        })
        .collect_vec()
}

fn path_to_string(path: &Path) -> String {
    let s = path.to_string_lossy();
    if path.is_dir() && !s.ends_with(MAIN_SEPARATOR) {
        format!("{}{}", s, MAIN_SEPARATOR)
    } else {
        s.into_owned()
    }
}

fn cmp_dir_entry(a: &DirEntry, b: &DirEntry) -> Ordering {
    if let Ok(type_a) = a.file_type()
        && let Ok(type_b) = b.file_type()
    {
        if type_a.is_dir() && !type_b.is_dir() {
            Ordering::Less
        } else if !type_a.is_dir() && type_b.is_dir() {
            Ordering::Greater
        } else {
            a.file_name()
                .to_string_lossy()
                .cmp(&b.file_name().to_string_lossy())
        }
    } else {
        Ordering::Equal
    }
}