pandora-kit 0.6.4

Interactive TUI toolkit for the Hefesto framework
Documentation
use clap::Args;
use crossterm::{
    event::{read, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, MouseEventKind},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::Rect,
    widgets::StatefulWidget,
    Terminal,
};
use hefesto_widgets::{FileBrowserPopup, FileBrowserState};

use crate::popup_config::{PopupConfig, PopupConfigurable};
use crate::{keybinds, style};
use std::path::PathBuf;

const FILE_GUIDE: &str = include_str!("../guides/FILE_GUIDE.md");

#[derive(Args)]
pub struct FileArgs {
    /// Starting directory (default: current dir)
    pub path: Option<PathBuf>,

    /// Select directories mode (Enter = cwd, Space = select dir)
    #[arg(short, long)]
    pub dirs: bool,

    /// Popup width (0 = auto)
    #[arg(short = 'W', long, default_value = "0")]
    pub width: u16,

    /// Enable multi-select (space to toggle, enter to confirm)
    #[arg(short, long)]
    pub multi: bool,

    /// Max selected items
    #[arg(short = 'M', long)]
    pub max: Option<usize>,

    /// Popup height (0 = auto)
    #[arg(short = 'H', long, default_value = "0")]
    pub height: u16,

    /// Show hidden files (dotfiles)
    #[arg(short = 's', long)]
    pub show_hidden: bool,

    #[arg(long)]
    pub guide: bool,
}

pub fn run(args: FileArgs) {
    if args.guide {
        println!("{}", FILE_GUIDE);
        return;
    }

    crate::tty::ensure_terminal_stdin();
    let mut tty: Box<dyn std::io::Write> = match std::fs::OpenOptions::new().write(true).open("/dev/tty") {
        Ok(f) => Box::new(f),
        Err(_) => Box::new(std::io::stdout()),
    };
    if enable_raw_mode().is_err() || execute!(tty, EnterAlternateScreen, EnableMouseCapture).is_err() {
        eprintln!("{} file: el terminal no es interactivo", crate::BIN_NAME);
        std::process::exit(1);
    }
    let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
    crate::tty::with_terminal_stdout(|| terminal.clear()).unwrap();
    crate::tty::with_terminal_stdout(|| terminal.hide_cursor()).unwrap();

    let start = args.path.clone().unwrap_or_else(|| std::env::current_dir().unwrap());
    let mut state = FileBrowserState {
        entries: vec![],
        items: vec![],
        cwd: PathBuf::new(),
        choose_popup_state: hefesto_widgets::ChoosePopupState::default(),
        show_hidden: args.show_hidden,
    };
    state.navigate_to(&start);

    let mut result: Option<Vec<PathBuf>> = None;
    let mut pending_g: bool = false;
    let mut drag = crate::drag::DragState::new();
    let mut origin: Option<(u16, u16)> = None;
    let mut resize_w: Option<u16> = None;
    let mut resize_h: Option<u16> = None;

    let mut cfg = PopupConfig::new()
        .header();
    if args.width > 0 { cfg = cfg.width(args.width); }
    if args.height > 0 { cfg = cfg.height(args.height); }

    while result.is_none() {
        let size = terminal.size().unwrap();
        let area = Rect::new(0, 0, size.width, size.height);

        let mut popup = FileBrowserPopup::new()
            .dir_style(ratatui::style::Style::new().fg(style::ACCENT))
            .file_style(ratatui::style::Style::new().fg(style::TEXT))
            .with_config(&cfg);
        if let Some(max) = args.max {
            popup = popup.max_selected(max);
        }
        if let Some((ox, oy)) = origin {
            popup = popup.origin(ox, oy);
        }
        if let Some(w) = resize_w { popup = popup.width(hefesto_widgets::PopupSize::Fixed(w)); }
        if let Some(h) = resize_h { popup = popup.height(hefesto_widgets::PopupSize::Fixed(h)); }

        let pr = popup.resolve_rect(area, &state);

        // Sync stored resize dimensions with actual rect after resolve_rect
        // clamping, so subsequent drags use the rendered dimensions and the
        // opposite edge doesn't drift.
        if resize_w.is_some() || resize_h.is_some() {
            resize_w = Some(pr.width);
            resize_h = Some(pr.height);
        }

        terminal
            .draw(|frame| {
                StatefulWidget::render(popup.clone(), frame.area(), frame.buffer_mut(), &mut state);
            })
            .unwrap();

        match read().unwrap() {
            Event::Key(key) => {
                if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
                    break;
                }
                match key.code {
                    keybinds::UP | keybinds::UP_ALT => { state.previous(); pending_g = false; },
                    keybinds::DOWN | keybinds::DOWN_ALT => { state.next(); pending_g = false; },
                    keybinds::TOGGLE_MULTI
                        if (args.multi || args.dirs) && !state.show_filter() =>
                    {
                        if args.multi {
                            if let Some(entry) = state.selected_entry() {
                                if args.dirs && entry.is_dir {
                                    state.toggle_cursor();
                                } else if !args.dirs && !entry.is_dir {
                                    state.toggle_cursor();
                                }
                            }
                        } else if args.dirs {
                            if let Some(entry) = state.selected_entry() {
                                if entry.is_dir {
                                    result = Some(vec![entry.path.clone()]);
                                }
                            }
                        }
                        pending_g = false;
                    }
                    keybinds::CONFIRM => {
                        if args.multi {
                            if state.chosen_indices().is_empty() {
                                state.toggle_cursor();
                            }
                            result = Some(state.chosen_paths());
                        } else if args.dirs {
                            result = Some(vec![state.cwd.clone()]);
                        } else if let Some(entry) = state.selected_entry() {
                            if entry.is_dir {
                                if entry.name == ".." {
                                    state.go_up();
                                } else {
                                    state.enter_directory();
                                }
                            } else {
                                result = Some(vec![entry.path.clone()]);
                            }
                        }
                        pending_g = false;
                    }
                    keybinds::OPEN_DIR if !state.show_filter() => {
                        if let Some(entry) = state.selected_entry() {
                            if entry.is_dir {
                                if entry.name == ".." {
                                    state.go_up();
                                } else {
                                    state.enter_directory();
                                }
                            }
                        }
                        pending_g = false;
                    }
                    keybinds::FILTER_BACKSPACE => {
                        if state.show_filter() {
                            state.delete_before_filter();
                        } else {
                            state.go_up();
                        }
                    }
                    keybinds::FILTER_LEFT => {
                        if state.show_filter() {
                            state.filter_cursor_left();
                        } else {
                            state.go_up();
                        }
                    }
                    keybinds::FILTER_RIGHT if state.show_filter() => {
                        state.filter_cursor_right();
                    }
                    keybinds::FILTER_HOME if state.show_filter() => {
                        state.filter_cursor_home();
                    }
                    keybinds::FILTER_END if state.show_filter() => {
                        state.filter_cursor_end();
                    }
                    keybinds::CANCEL | keybinds::CANCEL_ALT => {
                        if state.show_filter() {
                            state.set_show_filter(false);
                        } else {
                            break;
                        }
                    }
                    keybinds::FIRST => {
                        if pending_g {
                            state.first();
                            pending_g = false;
                        } else {
                            pending_g = true;
                        }
                    }
                    keybinds::LAST => {
                        state.last();
                        pending_g = false;
                    }
                    KeyCode::Char(c) if state.show_filter() => {
                        state.insert_filter_char(c);
                        pending_g = false;
                    }
                    keybinds::FILTER_ACTIVATE if !state.show_filter() => {
                        state.set_show_filter(true);
                    }
                    keybinds::TOGGLE_HIDDEN if !state.show_filter() => {
                        state.set_show_hidden(!state.show_hidden());
                        let cwd = state.cwd.clone();
                        state.navigate_to(&cwd);
                        pending_g = false;
                    }
                    _ => pending_g = false,
                }

                let visible = state.visible_count();
                if visible > 0 {
                    state.choose_popup_state.cursor = state.choose_popup_state.cursor.min(visible.saturating_sub(1));
                } else {
                    state.choose_popup_state.cursor = 0;
                }
            }
            Event::Mouse(mouse) => {
                let col = mouse.column;
                let row = mouse.row;
                match mouse.kind {
                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
                        let zone = crate::drag::default_zone_at(pr, col, row);
                        drag.begin(pr, col, row, zone);
                    }
                    MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
                        match drag.update(area, col, row) {
                            crate::drag::DragUpdate::Moved { x, y } => origin = Some((x, y)),
                            crate::drag::DragUpdate::Resized { x, y, w, h } => {
                                origin = Some((x, y));
                                resize_w = Some(w);
                                resize_h = Some(h);
                            }
                            crate::drag::DragUpdate::None => {}
                        }
                    }
                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
                        drag.end();
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }

    disable_raw_mode().unwrap();
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
    terminal.show_cursor().unwrap();

    if let Some(paths) = result {
        for path in &paths {
            println!("{}", path.display());
        }
        std::process::exit(if paths.is_empty() { 1 } else { 0 });
    }
    std::process::exit(1);
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::{Path, PathBuf};

    // FileEntry is not re-exported by hefesto_widgets, so we cannot
    // construct a fake state for snapshot tests without going through
    // `navigate_to`. Snapshot tests of the file browser would couple
    // to the local filesystem layout, which is non-portable. We stick
    // to behaviour tests on the state machine.

    fn state_at(path: &Path) -> FileBrowserState {
        let mut s = FileBrowserState {
            entries: vec![],
            items: vec![],
            cwd: PathBuf::new(),
            choose_popup_state: hefesto_widgets::ChoosePopupState::default(),
            show_hidden: false,
        };
        s.navigate_to(path);
        s
    }

    #[test]
    fn navigate_to_populates_entries() {
        let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
        assert!(!state.entries.is_empty());
        assert!(state.entries.iter().any(|e| e.is_dir));
    }

    #[test]
    fn navigate_to_first_entry_is_parent() {
        let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
        assert_eq!(state.entries[0].name, "..");
    }

    #[test]
    fn go_up_changes_cwd() {
        let mut state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
        let original = state.cwd.clone();
        state.go_up();
        assert_ne!(state.cwd, original);
    }

    #[test]
    fn default_state_has_no_selection() {
        let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
        assert_eq!(state.choose_popup_state.cursor, 0);
        assert!(!state.choose_popup_state.show_filter);
        assert!(!state.show_hidden);
    }

    #[test]
    fn visible_count_matches_entries() {
        let state = state_at(Path::new(env!("CARGO_MANIFEST_DIR")));
        assert_eq!(state.visible_count(), state.entries.len());
    }
}