pandora-kit 0.4.2

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

use crate::{keybinds, style};

const MAX_HEIGHT: u16 = 15;

const SELECT_GUIDE: &str = include_str!("../CHOOSE_GUIDE.md");

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

    #[arg(short, long, default_value = "Seleccionar")]
    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>,

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

fn centered_origin(area: Rect, w: u16, h: u16) -> (u16, u16) {
    ((area.width.saturating_sub(w)) / 2, (area.height.saturating_sub(h)) / 2)
}

fn contains(rect: Rect, col: u16, row: u16) -> bool {
    col >= rect.x
        && col < rect.x + rect.width
        && row >= rect.y
        && row < rect.y + rect.height
}

fn is_on_border(popup: Rect, col: u16, row: u16) -> bool {
    if !contains(popup, col, row) {
        return false;
    }
    let inner = Rect {
        x: popup.x + 1,
        y: popup.y + 1,
        width: popup.width.saturating_sub(2),
        height: popup.height.saturating_sub(2),
    };
    !contains(inner, col, row)
}

fn is_drag_area(popup: Rect, col: u16, row: u16) -> bool {
    if !contains(popup, col, row) {
        return false;
    }
    if is_on_border(popup, col, row) {
        return true;
    }
    let header_top = popup.y + 1;
    let header_bottom = header_top + 2;
    row >= header_top && row < header_bottom
}

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

    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!("{} choose: el terminal no es interactivo", crate::BIN_NAME);
        std::process::exit(1);
    }
    let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
    terminal.clear().unwrap();
    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::default();
    let mut drag_offset: Option<(u16, u16)> = None;
    let mut origin: Option<(u16, u16)> = None;
    let mut result: Option<Vec<String>> = None;
    let mut pending_g: bool = false;

    while result.is_none() {
        let size = terminal.size().unwrap();
        let area = Rect::new(0, 0, size.width, size.height);
        let pw = if args.width > 0 { args.width } else { 44 };
        let ph = if args.height > 0 {
            args.height.min(size.height.saturating_sub(2))
        } else {
            (items.len() as u16 + 4).max(6).min(MAX_HEIGHT).min(size.height.saturating_sub(2))
        };

        let (mut ox, mut oy) = origin.unwrap_or_else(|| centered_origin(area, pw, ph));
        ox = ox.min(area.width.saturating_sub(pw));
        oy = oy.min(area.height.saturating_sub(ph));

        let pr = Rect::new(ox, oy, pw.min(area.width), ph.min(area.height));

        let mut popup = ChoosePopup::new(items.clone())
            .title(&args.title)
            .header()
            .border_type(style::BORDER);
        if args.width > 0 {
            popup = popup.width(PopupSize::Fixed(pr.width));
        }
        if args.height > 0 {
            popup = popup.height(PopupSize::Fixed(pr.height));
        }
        if let Some(max) = args.max {
            popup = popup.max_selected(max);
        }
        popup = popup
            .origin(ox, oy)
            .filter_fill_bg(style::FILL)
            .filter_borders(Borders::LEFT)
            .filter_border_type(style::FILTER_BORDER);

        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::UP_ALT | keybinds::BACK_TAB => state.previous(),
                keybinds::DOWN | keybinds::DOWN_ALT | keybinds::TAB => state.next(items.len()),
                keybinds::TOGGLE_MULTI => {
                    if args.multi {
                        state.toggle(state.cursor);
                    } else if let Some(idx) = state.original_index(&items) {
                        let selected = vec![args.items[idx].clone()];
                        result = Some(selected);
                    }
                }
                keybinds::CONFIRM => {
                    if state.chosen_indices.is_empty() {
                        state.toggle_cursor();
                    }
                    let selected: Vec<String> = state
                        .chosen_indices
                        .iter()
                        .map(|&i| args.items[i].clone())
                        .collect();
                    result = Some(selected);
                }
                keybinds::CANCEL => result = Some(vec![]),
                keybinds::FIRST => {
                    if pending_g {
                        state.first();
                        pending_g = false;
                    } else {
                        pending_g = true;
                    }
                }
                keybinds::LAST => state.last(items.len()),
                _ => pending_g = false,
                }
            }
            },
            Event::Mouse(mouse) => {
                let col = mouse.column;
                let row = mouse.row;

                match mouse.kind {
                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
                        if is_drag_area(pr, col, row) {
                            let ox = col.saturating_sub(pr.x);
                            let oy = row.saturating_sub(pr.y);
                            drag_offset = Some((ox, oy));
                        } else 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) => {
                        if let Some((dx, dy)) = drag_offset {
                            let max_w = area.width.saturating_sub(pw);
                            let max_h = area.height.saturating_sub(ph);
                            let nx = (col as i16 - dx as i16).clamp(0, max_w as i16) as u16;
                            let ny = (row as i16 - dy as i16).clamp(0, max_h as i16) as u16;
                            origin = Some((nx, ny));
                        }
                    }
                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
                        drag_offset = None;
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }

    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);
    }
}