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 {
#[arg(short, long, default_value = "¿Deseas continuar?")]
pub message: String,
#[arg(short, long, default_value = "Confirmación")]
pub title: String,
#[arg(short, long, default_value = "warning")]
pub variant: String,
#[arg(short = 'y', long, default_value = "Confirmar")]
pub confirm: String,
#[arg(short = 'n', long, default_value = "Cancelar")]
pub cancel: String,
#[arg(short = 'W', long, default_value = "0")]
pub width: u16,
#[arg(short = 'H', long, default_value = "0")]
pub height: u16,
#[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);
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());
});
}
#[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));
}
#[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);
assert_eq!(confirm.y, cancel.y);
assert!(cancel.x > confirm.x);
}
#[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));
}
}