hefesto-widgets 0.3.0

Ratatui widgets for the Hefesto TUI toolkit
Documentation
use std::path::{Path, PathBuf};

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::{Color, Style},
    widgets::StatefulWidget,
};

use crate::popups::choose_popup::{ChoosePopup, ChoosePopupState};
use crate::popup::PopupSize;
use crate::{BorderType, DEFAULT_HIGHLIGHT, POPUP_WIDTH};

#[derive(Clone)]
pub struct FileEntry {
    pub name: String,
    pub is_dir: bool,
    pub path: PathBuf,
}

const DEFAULT_DIR_ICON: &str = "📁 ";
const DEFAULT_FILE_ICON: &str = "📄 ";

impl Default for FileBrowserState {
    fn default() -> Self {
        Self {
            entries: Vec::new(),
            items: Vec::new(),
            cwd: PathBuf::new(),
            choose_popup_state: ChoosePopupState::default(),
            show_hidden: false,
        }
    }
}

#[derive(Clone)]
pub struct FileBrowserState {
    pub entries: Vec<FileEntry>,
    pub items: Vec<(String, Style)>,
    pub cwd: PathBuf,
    pub choose_popup_state: ChoosePopupState,
    pub show_hidden: bool,
}

impl FileBrowserState {
    pub fn navigate_to(&mut self, path: &Path) {
        if let Ok(read_dir) = std::fs::read_dir(path) {
            let mut entries: Vec<FileEntry> = read_dir
                .filter_map(|e| e.ok())
                .filter(|e| {
                    if self.show_hidden {
                        true
                    } else {
                        let name = e.file_name();
                        let name = name.to_string_lossy();
                        !name.starts_with('.')
                    }
                })
                .map(|e| {
                    let path = e.path();
                    let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
                    FileEntry {
                        name: e.file_name().to_string_lossy().to_string(),
                        is_dir,
                        path,
                    }
                })
                .collect();

            entries.sort_by(|a, b| {
                if a.is_dir != b.is_dir {
                    b.is_dir.cmp(&a.is_dir)
                } else {
                    a.name.to_lowercase().cmp(&b.name.to_lowercase())
                }
            });

            if let Some(parent) = path.parent() {
                entries.insert(
                    0,
                    FileEntry {
                        name: "..".to_string(),
                        is_dir: true,
                        path: parent.to_path_buf(),
                    },
                );
            }

            self.items = entries
                .iter()
                .map(|e| {
                    let text = if e.is_dir {
                        if e.name == ".." {
                            "↑ ..".to_string()
                        } else {
                            format!("{}{}", DEFAULT_DIR_ICON, e.name)
                        }
                    } else {
                        format!("{}{}", DEFAULT_FILE_ICON, e.name)
                    };
                    let style = if e.is_dir {
                        Style::new().fg(Color::Cyan)
                    } else {
                        Style::new().fg(Color::White)
                    };
                    (text, style)
                })
                .collect();

            self.entries = entries;
            self.cwd = path.to_path_buf();
            self.choose_popup_state = ChoosePopupState::default();
            self.choose_popup_state.scroll_list_state.follow = false;
            self.choose_popup_state.scroll_list_state.select(Some(0));
        }
    }

    pub fn go_up(&mut self) {
        let parent = self.cwd.parent().map(|p| p.to_path_buf());
        if let Some(p) = parent {
            self.navigate_to(&p);
        }
    }

    pub fn enter_directory(&mut self) {
        let target = self.selected_entry().map(|e| (e.is_dir, e.path.clone()));
        if let Some((true, path)) = target {
            self.navigate_to(&path);
        }
    }

    pub fn selected_entry(&self) -> Option<&FileEntry> {
        if self.choose_popup_state.show_filter && !self.choose_popup_state.text_input.content.is_empty() {
            self.filtered_indices().get(self.choose_popup_state.cursor).and_then(|&i| self.entries.get(i))
        } else {
            self.entries.get(self.choose_popup_state.cursor)
        }
    }

    pub fn current_path(&self) -> Option<PathBuf> {
        self.selected_entry().map(|e| e.path.clone())
    }

    pub fn select(&mut self, index: usize) {
        let count = self.visible_count();
        self.choose_popup_state.cursor = index.min(count.saturating_sub(1));
        self.choose_popup_state.scroll_list_state.select(Some(self.choose_popup_state.cursor));
    }

    pub fn next(&mut self) {
        let count = self.visible_count();
        self.choose_popup_state.next(count);
    }

    pub fn previous(&mut self) {
        self.choose_popup_state.previous();
    }

    pub fn first(&mut self) {
        self.choose_popup_state.first();
    }

    pub fn last(&mut self) {
        let count = self.visible_count();
        self.choose_popup_state.last(count);
    }

    pub fn toggle(&mut self, idx: usize) {
        self.choose_popup_state.toggle(idx);
    }

    pub fn toggle_cursor(&mut self) {
        self.choose_popup_state.toggle_cursor();
    }

    pub fn chosen_indices(&self) -> &std::collections::HashSet<usize> {
        &self.choose_popup_state.chosen_indices
    }

    pub fn chosen_paths(&self) -> Vec<PathBuf> {
        self.choose_popup_state
            .chosen_indices
            .iter()
            .filter_map(|&i| self.entries.get(i).map(|e| e.path.clone()))
            .collect()
    }

    // Filter API

    pub fn show_filter(&self) -> bool {
        self.choose_popup_state.show_filter
    }

    pub fn set_show_filter(&mut self, show: bool) {
        self.choose_popup_state.show_filter = show;
    }

    pub fn insert_filter_char(&mut self, c: char) {
        self.choose_popup_state.insert_filter_char(c);
    }

    pub fn delete_before_filter(&mut self) {
        self.choose_popup_state.delete_before_filter();
    }

    pub fn delete_at_filter(&mut self) {
        self.choose_popup_state.delete_at_filter();
    }

    pub fn filter_cursor_left(&mut self) {
        self.choose_popup_state.filter_cursor_left();
    }

    pub fn filter_cursor_right(&mut self) {
        self.choose_popup_state.filter_cursor_right();
    }

    pub fn filter_cursor_home(&mut self) {
        self.choose_popup_state.filter_cursor_home();
    }

    pub fn filter_cursor_end(&mut self) {
        self.choose_popup_state.filter_cursor_end();
    }

    pub fn filtered_indices(&self) -> Vec<usize> {
        self.choose_popup_state.filtered_indices(&self.items)
    }

    pub fn visible_count(&self) -> usize {
        self.choose_popup_state.visible_count(&self.items)
    }

    pub fn original_index(&self) -> Option<usize> {
        self.choose_popup_state.original_index(&self.items)
    }

    pub fn filter_content(&self) -> &str {
        &self.choose_popup_state.text_input.content
    }

    pub fn max_selected(&self) -> Option<usize> {
        self.choose_popup_state.max_selected
    }

    pub fn set_max_selected(&mut self, max: Option<usize>) {
        self.choose_popup_state.max_selected = max;
    }

    pub fn show_hidden(&self) -> bool {
        self.show_hidden
    }

    pub fn set_show_hidden(&mut self, show: bool) {
        self.show_hidden = show;
    }
}

#[derive(Clone)]
pub struct FileBrowserPopup {
    dir_style: Style,
    file_style: Style,
    highlight_style: Style,
    width: PopupSize,
    dir_icon: String,
    file_icon: String,
    border_color: Option<Color>,
    border_type: BorderType,
    padding: u16,
    header: bool,
    max_selected: Option<usize>,
}

impl FileBrowserPopup {
    pub fn new() -> Self {
        Self {
            dir_style: Style::new().fg(Color::Cyan),
            file_style: Style::new().fg(Color::White),
            highlight_style: DEFAULT_HIGHLIGHT,
            width: POPUP_WIDTH,
            dir_icon: "📁 ".to_string(),
            file_icon: "📄 ".to_string(),
            border_color: None,
            border_type: BorderType::Rounded,
            padding: 0,
            header: false,
            max_selected: None,
        }
    }

    pub fn dir_style(mut self, style: Style) -> Self {
        self.dir_style = style;
        self
    }

    pub fn file_style(mut self, style: Style) -> Self {
        self.file_style = style;
        self
    }

    pub fn highlight_style(mut self, style: Style) -> Self {
        self.highlight_style = style;
        self
    }

    pub fn width(mut self, w: PopupSize) -> Self {
        self.width = w;
        self
    }

    pub fn dir_icon(mut self, icon: &str) -> Self {
        self.dir_icon = icon.to_string();
        self
    }

    pub fn file_icon(mut self, icon: &str) -> Self {
        self.file_icon = icon.to_string();
        self
    }

    pub fn border_color(mut self, color: Color) -> Self {
        self.border_color = Some(color);
        self
    }

    pub fn border_type(mut self, bt: BorderType) -> Self {
        self.border_type = bt;
        self
    }

    pub fn padding(mut self, p: u16) -> Self {
        self.padding = p;
        self
    }

    pub fn header(mut self) -> Self {
        self.header = true;
        self
    }

    pub fn max_selected(mut self, max: usize) -> Self {
        self.max_selected = Some(max);
        self
    }

    pub fn no_max_selected(mut self) -> Self {
        self.max_selected = None;
        self
    }
}

impl StatefulWidget for FileBrowserPopup {
    type State = FileBrowserState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        let path_str = state.cwd.to_string_lossy().to_string();
        let max_visible = area.height.saturating_sub(7).min(20).max(3) as usize;
        let visible = state.entries.len().min(max_visible);
        let total_height = visible as u16 + 5;

        let items: Vec<(String, Style)> = state
            .items
            .iter()
            .enumerate()
            .map(|(i, (text, _))| {
                let is_dir = state.entries.get(i).map(|e| e.is_dir).unwrap_or(false);
                let style = if is_dir { self.dir_style } else { self.file_style };
                (text.clone(), style)
            })
            .collect();

        let mut choose_popup = ChoosePopup::new(items)
            .title(&path_str)
            .width(self.width)
            .border_type(self.border_type)
            .padding(self.padding)
            .height(PopupSize::Fixed(total_height))
            .highlight_style(self.highlight_style);

        if let Some(color) = self.border_color {
            choose_popup = choose_popup.border_color(color);
        }
        if self.header {
            choose_popup = choose_popup.header();
        }
        if let Some(max) = self.max_selected {
            choose_popup = choose_popup.max_selected(max);
        }

        choose_popup.render(area, buf, &mut state.choose_popup_state);
    }
}