pandora-kit 0.6.4

Interactive TUI toolkit for the Hefesto framework
Documentation
use crossterm::{
    event::{
        read, DisableMouseCapture, EnableMouseCapture, Event,
        KeyModifiers, MouseEventKind,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Layout, Rect},
    text::Line,
    widgets::Widget,
    Terminal,
};
use hefesto_widgets::{ConfirmationVariant, ThemedConfirmationPopup};

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

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

#[derive(clap::Args)]
pub struct ConfirmArgs {
    /// Body message displayed inside the popup
    #[arg(short, long, default_value = "¿Deseas continuar?")]
    pub message: String,

    /// Title displayed in the popup header
    #[arg(short, long, default_value = "Confirmación")]
    pub title: String,

    /// Variant color: success, warning, danger, none
    #[arg(short, long, default_value = "warning")]
    pub variant: String,

    /// Confirm button label
    #[arg(short = 'y', long, default_value = "Confirmar")]
    pub confirm: String,

    /// Cancel button label
    #[arg(short = 'n', long, default_value = "Cancelar")]
    pub cancel: String,

    /// 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,

    /// Show detailed guide for this command
    #[arg(long)]
    pub guide: bool,
}

fn parse_variant(s: &str) -> ConfirmationVariant {
    match s.to_lowercase().as_str() {
        "success" => ConfirmationVariant::Success,
        "warning" => ConfirmationVariant::Warning,
        "danger" => ConfirmationVariant::Danger,
        _ => ConfirmationVariant::None,
    }
}

fn button_rects(popup: Rect) -> (Rect, Rect) {
    let inner = Rect {
        x: popup.x + 1,
        y: popup.y + 1,
        width: popup.width.saturating_sub(2),
        height: popup.height.saturating_sub(2),
    };

    let chunks = Layout::vertical([
        Constraint::Length(2),
        Constraint::Min(0),
        Constraint::Length(2),
    ])
    .split(inner);

    let options = chunks[2];
    let options_inner = Rect {
        x: options.x + 1,
        y: options.y + 1,
        width: options.width.saturating_sub(2),
        height: options.height.saturating_sub(1),
    };

    let horiz = Layout::horizontal([
        Constraint::Percentage(48),
        Constraint::Length(1),
        Constraint::Percentage(48),
    ])
    .split(options_inner);

    (horiz[0], horiz[2])
}

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

    let variant = parse_variant(&args.variant);

    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()),
    };
    enable_raw_mode().unwrap();
    execute!(tty, EnterAlternateScreen, EnableMouseCapture).unwrap();
    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 title = args.title.as_str();
    let body = vec![Line::from(args.message.as_str())];

    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 mut drag = crate::drag::DragState::new();
    let mut popup_pos: Option<(u16, u16)> = None;
    let mut resize_w: Option<u16> = None;
    let mut resize_h: Option<u16> = None;
    let mut result = None;

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

        let mut popup = ThemedConfirmationPopup::new(title, body.clone(), variant)
            .options(&args.confirm, &args.cancel)
            .with_config(&cfg);

        if let Some((x, y)) = popup_pos {
            popup = popup.position(x, y);
        }
        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);

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

        let (confirm_rect, cancel_rect) = button_rects(pr);

        terminal
            .draw(|frame| {
                let area = frame.area();
                popup.clone().render(area, frame.buffer_mut());
            })
            .unwrap();

        match read().unwrap() {
            Event::Key(key) => {
                if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
                    result = Some(1);
                } else {
                match key.code {
                    keybinds::CONFIRM_YES | keybinds::CONFIRM_YES_UPPER | keybinds::CONFIRM => result = Some(0),
                    keybinds::CONFIRM_NO | keybinds::CONFIRM_NO_UPPER | keybinds::CANCEL | keybinds::CANCEL_ALT => result = Some(1),
                    _ => {}
                }
                }
            },
            Event::Mouse(mouse) => {
                let col = mouse.column;
                let row = mouse.row;

                match mouse.kind {
                    MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
                        if crate::drag::contains(confirm_rect, col, row) {
                            result = Some(0);
                        } else if crate::drag::contains(cancel_rect, col, row) {
                            result = Some(1);
                        } else {
                            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 } => popup_pos = Some((x, y)),
                            crate::drag::DragUpdate::Resized { x, y, w, h } => {
                                popup_pos = Some((x, y));
                                resize_w = Some(w);
                                resize_h = Some(h);
                            }
                            crate::drag::DragUpdate::None => {}
                        }
                    }
                    MouseEventKind::Up(crossterm::event::MouseButton::Left) => {
                        drag.end();
                    }
                    _ => {}
                }
            }
            _ => {}
        }
    }

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

    std::process::exit(code);
}

#[cfg(test)]
mod tests {
    use super::*;
    use insta::assert_snapshot;
    use ratatui::{backend::TestBackend, Terminal};
    use ratatui::text::Line;

    fn render_popup(name: &str, popup: ThemedConfirmationPopup<'_>, area: Rect) {
        let backend = TestBackend::new(60, 20);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|f| popup.clone().render(area, f.buffer_mut()))
            .unwrap();
        insta::with_settings!({
            snapshot_path => "confirm/snapshots",
            prepend_module_to_snapshot => false,
        }, {
            assert_snapshot!(name, terminal.backend());
        });
    }

    // ── parse_variant ──

    #[test]
    fn parse_variant_success() {
        assert!(matches!(parse_variant("success"), ConfirmationVariant::Success));
        assert!(matches!(parse_variant("SUCCESS"), ConfirmationVariant::Success));
        assert!(matches!(parse_variant("Success"), ConfirmationVariant::Success));
    }

    #[test]
    fn parse_variant_warning() {
        assert!(matches!(parse_variant("warning"), ConfirmationVariant::Warning));
    }

    #[test]
    fn parse_variant_danger() {
        assert!(matches!(parse_variant("danger"), ConfirmationVariant::Danger));
    }

    #[test]
    fn parse_variant_unknown_is_none() {
        assert!(matches!(parse_variant("nope"), ConfirmationVariant::None));
        assert!(matches!(parse_variant(""), ConfirmationVariant::None));
    }

    // ── geometry helpers ──

    #[test]
    fn button_rects_split_into_two() {
        let popup = Rect::new(0, 0, 40, 10);
        let (confirm, cancel) = button_rects(popup);
        assert!(confirm.width > 0);
        assert!(cancel.width > 0);
        // confirm and cancel must be at the same y row
        assert_eq!(confirm.y, cancel.y);
        // cancel must be to the right of confirm
        assert!(cancel.x > confirm.x);
    }

    // ── snapshots ──

    #[test]
    fn snapshot_default() {
        let body = vec![Line::from("¿Deseas continuar?")];
        let popup = ThemedConfirmationPopup::new("Confirmación", body, ConfirmationVariant::Warning);
        render_popup("confirm_default", popup, Rect::new(0, 0, 60, 20));
    }

    #[test]
    fn snapshot_success() {
        let body = vec![Line::from("Archivo guardado")];
        let popup = ThemedConfirmationPopup::new("Listo", body, ConfirmationVariant::Success);
        render_popup("confirm_success", popup, Rect::new(0, 0, 60, 20));
    }

    #[test]
    fn snapshot_danger() {
        let body = vec![Line::from("Esto eliminará todo")];
        let popup = ThemedConfirmationPopup::new("Peligro", body, ConfirmationVariant::Danger);
        render_popup("confirm_danger", popup, Rect::new(0, 0, 60, 20));
    }
}