pandora-kit 0.6.6

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::{ChoosePopup, ChoosePopupState, PopupSize};

use crate::popup_config::{PopupConfig, PopupConfigurable};
use crate::{badge, keybinds, style};

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

#[derive(Args)]
pub struct FilterArgs {
    #[arg(required = true)]
    pub items: Vec<String>,

    #[arg(short, long, default_value = "Filtrar")]
    pub title: String,

    #[arg(short, long)]
    pub multi: bool,

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

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

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

    #[command(flatten)]
    pub badge: badge::BadgeArgs,

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

pub fn run(args: FilterArgs) {
    if args.guide {
        println!("{}", FILTER_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!("{} filter: 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 items: Vec<(String, ratatui::style::Style)> = args.items.iter().map(|s| (s.clone(), ratatui::style::Style::default())).collect();
    let mut state = ChoosePopupState {
        show_filter: true,
        ..Default::default()
    };
    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 result: Option<Vec<String>> = None;

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

    let badge_specs = args.badge.prepare_specs();
    let badge_stack = if badge_specs.is_empty() { None } else { Some(badge::build_badge_stack(&badge_specs)) };

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

        let mut popup = ChoosePopup::new(items.clone())
            .title(&args.title)
            .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);
        }


        // SYNC: if hefesto-widgets changes its internal height formula
        // for ChoosePopup, update src/popup_rect.rs and this call.
        let h = match cfg.height {
            Some(uh) => PopupSize::Fixed(uh),
            None => {
                let extra = if cfg.header { 4 } else { 0 };
                PopupSize::Fixed(crate::popup_rect::choose_default_height(items.len()) + extra)
            }
        };
        popup = popup.height(h);
        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);
        }

        if let Some(ref stack) = badge_stack {
            popup = popup.badges(stack.clone());
        }

        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 {
                    result = Some(vec![]);
                } else {
                match key.code {
                keybinds::UP | keybinds::BACK_TAB => state.cursor = state.cursor.saturating_sub(1),
                keybinds::DOWN | keybinds::TAB => {
                    let visible = state.visible_count(&items);
                    if visible > 0 {
                        state.cursor = (state.cursor + 1).min(visible.saturating_sub(1));
                    }
                }
                keybinds::CONFIRM => {
                    if state.chosen_indices.is_empty() {
                        state.toggle_selected(&items);
                    }
                    let selected: Vec<String> = state
                        .chosen_indices
                        .iter()
                        .map(|&i| args.items[i].clone())
                        .collect();
                    result = Some(selected);
                }
                keybinds::CANCEL => result = Some(vec![]),
                keybinds::TOGGLE_MULTI => {
                    if args.multi {
                        state.toggle_selected(&items);
                    } else if let Some(idx) = state.original_index(&items) {
                        let selected = vec![args.items[idx].clone()];
                        result = Some(selected);
                    }
                }
                keybinds::FILTER_BACKSPACE => state.delete_before_filter(),
                keybinds::FILTER_LEFT => state.filter_cursor_left(),
                keybinds::FILTER_RIGHT => state.filter_cursor_right(),
                keybinds::FILTER_HOME => state.filter_cursor_home(),
                keybinds::FILTER_END => state.filter_cursor_end(),
                KeyCode::Char(c) => state.insert_filter_char(c),
                _ => {}
                }
            }
            },
            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);
                        if !drag.is_dragging() {
                            if let Some(idx) = popup.item_at(&state, pr, row) {
                                if args.multi {
                                    state.toggle(idx);
                                } else {
                                    state.cursor = idx;
                                }
                            }
                        }
                    }
                    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();
                    }
                    _ => {}
                }
            }
            _ => {}
        }

        let visible = state.visible_count(&items);
        if visible > 0 {
            state.cursor = state.cursor.min(visible.saturating_sub(1));
        } else {
            state.cursor = 0;
        }
    }

    let selected = result.unwrap();
    disable_raw_mode().unwrap();
    execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
    terminal.show_cursor().unwrap();

    for item in &selected {
        println!("{}", item);
    }

    if selected.is_empty() {
        std::process::exit(1);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use hefesto_widgets::TextInputState;
    use insta::assert_snapshot;
    use ratatui::{backend::TestBackend, style::{Color, Style}, Terminal};

    fn render_popup(name: &str, popup: ChoosePopup<'_>, state: &mut ChoosePopupState) {
        let backend = TestBackend::new(60, 20);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| f.render_stateful_widget(popup, f.area(), state))
            .unwrap();
        insta::with_settings!({
            snapshot_path => "filter/snapshots",
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(name, terminal.backend());
        });
    }

    fn items() -> Vec<(String, Style)> {
        vec![
            ("Manzana".to_string(), Style::default()),
            ("Banana".to_string(), Style::default()),
            ("Cereza".to_string(), Style::default()),
            ("Durazno".to_string(), Style::default()),
        ]
    }

    // ── snapshots ──

    fn items_one() -> Vec<(String, Style)> {
        vec![("Único".to_string(), Style::default())]
    }

    fn popup_h(title: &str) -> ChoosePopup<'_> {
        // Fixed height to avoid the widget drawing a scrollbar in the
        // default-sized terminal area (60x20 with Auto height triggers
        // overflow rendering for 4 items + filter input).
        ChoosePopup::new(items())
            .title(title)
            .height(hefesto_widgets::PopupSize::Fixed(10))
    }

    #[test]
    fn snapshot_default() {
        let popup = popup_h("Filtrar");
        let mut state = ChoosePopupState {
            show_filter: true,
            ..Default::default()
        };
        render_popup("filter_default", popup, &mut state);
    }

    #[test]
    fn snapshot_with_filter_text() {
        let popup = popup_h("Filtrar");
        let mut state = ChoosePopupState {
            show_filter: true,
            text_input: TextInputState {
                content: "an".to_string(),
                cursor: 2,
            },
            ..Default::default()
        };
        render_popup("filter_with_text", popup, &mut state);
    }

    #[test]
    fn snapshot_filtered_and_selected() {
        let popup = popup_h("Filtrar");
        let mut state = ChoosePopupState {
            show_filter: true,
            chosen_indices: std::collections::HashSet::from([1]),
            text_input: TextInputState {
                content: "an".to_string(),
                cursor: 2,
            },
            ..Default::default()
        };
        render_popup("filter_with_selection", popup, &mut state);
    }

    #[test]
    fn snapshot_single_item() {
        // Runtime height for 1 item + header + filter = (1+4) + 4 = 9
        let popup = ChoosePopup::new(items_one())
            .title("Filtrar")
            .header()
            .height(hefesto_widgets::PopupSize::Fixed(9));
        let mut state = ChoosePopupState {
            show_filter: true,
            ..Default::default()
        };
        render_popup("filter_single_item", popup, &mut state);
    }

    #[test]
    fn snapshot_with_badges() {
        let specs = vec![
            badge::prepare_badge(
                badge::BadgeParts { text: "filtered".into(), color: Color::Cyan, anchor: None, width: None, lines: None },
                badge::parse_anchor("tl").unwrap(), 1, None,
            ),
        ];
        let stack = badge::build_badge_stack(&specs);
        let mut state = ChoosePopupState { show_filter: true, ..Default::default() };
        let backend = TestBackend::new(60, 20);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| {
                let popup = popup_h("Filtrar").badges(stack.clone());
                f.render_stateful_widget(popup, f.area(), &mut state);
            })
            .unwrap();
        insta::with_settings!({
            snapshot_path => "filter/snapshots",
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!("filter_with_badges", terminal.backend());
        });
    }
}