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,
style::Style,
widgets::StatefulWidget,
Terminal,
};
use hefesto_widgets::{SpinPopup, SpinState, SpinVariant, PopupSize};
use crate::popup_config::{PopupConfig, PopupConfigurable};
use crate::{keybinds, style};
use std::io::BufRead;
use std::process::Stdio;
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
const TICK_MS: u64 = 100;
const MAX_OUTPUT_LINES: usize = 200;
const CMD_ICON: &str = " ";
const SPIN_GUIDE: &str = include_str!("../guides/SPIN_GUIDE.md");
#[derive(Args)]
pub struct SpinArgs {
#[arg(short, long, default_value = "Procesando...")]
pub title: String,
#[arg(short = 'c', long)]
pub cmd: Option<String>,
#[arg(last = true)]
pub command: Vec<String>,
#[arg(short = 'l', long)]
pub logs: bool,
#[arg(short = 'v', long)]
pub verbose: bool,
#[arg(short = 'w', long)]
pub wait: bool,
#[arg(short = 'a', long, default_value = "dots")]
pub animation: 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,
}
struct ResolvedCmd {
display: String,
program: String,
args: Vec<String>,
}
fn resolve_cmd(cmd: &Option<String>, command: &[String]) -> Option<ResolvedCmd> {
if let Some(c) = cmd {
let parts: Vec<&str> = c.split_whitespace().collect();
let first = parts.first()?;
Some(ResolvedCmd {
display: c.clone(),
program: first.to_string(),
args: parts[1..].iter().map(|s| s.to_string()).collect(),
})
} else if !command.is_empty() {
Some(ResolvedCmd {
display: command.join(" "),
program: command[0].clone(),
args: command[1..].to_vec(),
})
} else {
None
}
}
fn parse_variant(s: &str) -> SpinVariant {
match s.to_lowercase().as_str() {
"line" => SpinVariant::Line,
"dots2" => SpinVariant::Dots2,
"bounce" => SpinVariant::Bounce,
"pulse" => SpinVariant::Pulse,
"arrows" => SpinVariant::Arrows,
"square" => SpinVariant::Square,
"clock" => SpinVariant::Clock,
_ => SpinVariant::Dots,
}
}
pub fn run(args: SpinArgs) {
if args.guide {
println!("{}", SPIN_GUIDE);
return;
}
if resolve_cmd(&args.cmd, &args.command).is_none() {
eprintln!("{} spin: se requiere un comando. Usa -c \"comando\" o -- comando.", crate::BIN_NAME);
std::process::exit(1);
}
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!("{} spin: 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 mut spin_state = SpinState::default();
let resolved = resolve_cmd(&args.cmd, &args.command);
let cmd_display = resolved.as_ref().map_or(String::new(), |r| r.display.clone());
let has_cmd = !cmd_display.is_empty();
let output_lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = mpsc::channel::<Option<(i32, Vec<u8>, Vec<u8>)>>();
let cmd_thread = if has_cmd {
let r = resolved.unwrap();
let tx = tx.clone();
let lines = output_lines.clone();
let verbose = args.verbose;
Some(thread::spawn(move || {
if verbose {
let mut child = match std::process::Command::new(&r.program)
.args(&r.args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = tx.send(None);
return Err(e);
}
};
let out_lines = lines.clone();
let out_reader = if let Some(stdout) = child.stdout.take() {
Some(thread::spawn(move || {
for line in std::io::BufReader::new(stdout).lines() {
if let Ok(l) = line {
let mut buf = out_lines.lock().unwrap();
buf.push(l);
if buf.len() > MAX_OUTPUT_LINES {
buf.remove(0);
}
}
}
}))
} else {
None
};
let err_lines = lines.clone();
let err_reader = if let Some(stderr) = child.stderr.take() {
Some(thread::spawn(move || {
for line in std::io::BufReader::new(stderr).lines() {
if let Ok(l) = line {
let mut buf = err_lines.lock().unwrap();
buf.push(l);
if buf.len() > MAX_OUTPUT_LINES {
buf.remove(0);
}
}
}
}))
} else {
None
};
let status = child.wait();
if let Some(h) = out_reader { let _ = h.join(); }
if let Some(h) = err_reader { let _ = h.join(); }
let code = status.ok().and_then(|s| s.code()).unwrap_or(1);
let _ = tx.send(Some((code, Vec::new(), Vec::new())));
Ok(())
} else {
let output = std::process::Command::new(&r.program)
.args(&r.args)
.output();
match output {
Ok(o) => {
let code = o.status.code().unwrap_or(1);
let _ = tx.send(Some((code, o.stdout, o.stderr)));
Ok(())
}
Err(e) => {
let _ = tx.send(None);
Err(e)
}
}
}
}))
} else {
None
};
let mut should_exit = false;
let mut command_finished = false;
let mut waiting = false;
let mut cmd_exit: Option<(i32, Vec<u8>, Vec<u8>)> = None;
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 pending_g: bool = false;
let mut cfg = PopupConfig::new();
if args.width > 0 { cfg = cfg.width(args.width); }
if args.height > 0 { cfg = cfg.height(args.height); }
while !should_exit {
let size = terminal.size().unwrap();
let area = Rect::new(0, 0, size.width, size.height);
let output_snapshot: Vec<String> = {
let locked = output_lines.lock().unwrap();
let start = locked.len().saturating_sub(100);
locked[start..].to_vec()
};
let mut output_strs: Vec<&str> = output_snapshot.iter().map(|s| s.as_str()).collect();
if args.height > 0 {
let cmd_rows: u16 = if has_cmd { 2 } else { 0 };
let footer_rows: u16 = if spin_state.finished { 2 } else { 0 };
let out_rows = args.height.saturating_sub(4).saturating_sub(cmd_rows).saturating_sub(footer_rows);
if (output_strs.len() as u16) > out_rows {
output_strs.truncate(out_rows as usize);
}
let pad = (out_rows as usize).saturating_sub(output_strs.len());
let blanks: Vec<&str> = std::iter::repeat("").take(pad).collect();
output_strs.extend(blanks);
}
let mut popup = SpinPopup::new()
.title(&args.title)
.variant(parse_variant(&args.animation))
.spinner_style(Style::new().fg(style::ACCENT))
.command_style(Style::new().fg(style::TEXT))
.output_style(Style::new().fg(style::MUTED))
.with_config(&cfg);
if let Some((ox, oy)) = origin {
popup = popup.origin(ox, oy);
}
let cmd_display_icon = if has_cmd {
Some(format!("{}{}", CMD_ICON, cmd_display))
} else {
None
};
if let Some(ref display) = cmd_display_icon {
popup = popup.command(display);
}
if args.verbose {
let max_rows = if args.height > 0 {
args.height.saturating_sub(6)
} else {
10
};
popup = popup.max_output_rows(max_rows.max(3));
}
if !output_strs.is_empty() {
popup = popup.output_lines(&output_strs);
}
let max_output_rows: u16 = if args.verbose {
let mr = if args.height > 0 { args.height.saturating_sub(6) } else { 10 };
mr.max(3)
} else {
10
};
let h = match cfg.height {
Some(uh) => PopupSize::Fixed(uh),
None => PopupSize::Fixed(crate::popup_rect::spin_default_height(
has_cmd,
output_strs.len(),
max_output_rows,
spin_state.finished,
)),
};
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, &spin_state);
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, frame.area(), frame.buffer_mut(), &mut spin_state);
})
.unwrap();
if let Some(ref handle) = cmd_thread {
if !command_finished {
if let Ok(Some(result)) = rx.try_recv() {
cmd_exit = Some(result);
command_finished = true;
} else if rx.try_recv().is_ok() {
command_finished = true;
}
}
if command_finished && handle.is_finished() {
spin_state.finished = true;
spin_state.exit_code = cmd_exit.as_ref().map(|(code, _, _)| *code);
if args.wait {
waiting = true;
} else {
should_exit = true;
continue;
}
}
}
if crossterm::event::poll(Duration::from_millis(TICK_MS)).unwrap_or(false) {
match read().unwrap() {
Event::Key(key) => {
if key.code == keybinds::EMERGENCY && key.modifiers == KeyModifiers::CONTROL {
should_exit = true;
} else if args.verbose {
match key.code {
keybinds::UP | keybinds::UP_ALT => {
spin_state.output_previous();
pending_g = false;
}
keybinds::DOWN | keybinds::DOWN_ALT => {
spin_state.output_next(output_strs.len());
pending_g = false;
}
keybinds::FIRST => {
if pending_g {
spin_state.scroll_list.select(Some(0));
pending_g = false;
} else {
pending_g = true;
}
}
keybinds::LAST => {
spin_state.output_last(output_strs.len());
pending_g = false;
}
_ => pending_g = false,
}
}
if waiting {
match key.code {
keybinds::CONFIRM | keybinds::CANCEL | keybinds::CANCEL_ALT => {
should_exit = true;
}
_ => {}
}
} else {
match key.code {
keybinds::CANCEL | keybinds::CANCEL_ALT => {
if cmd_thread.is_none() {
should_exit = true;
}
}
_ => {}
}
}
}
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();
}
_ => {}
}
}
_ => {}
}
}
spin_state.tick(10);
}
disable_raw_mode().unwrap();
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture).unwrap();
terminal.show_cursor().unwrap();
if let Some((code, stdout, stderr)) = cmd_exit {
if args.logs {
use std::io::Write;
std::io::stdout().write_all(&stdout).unwrap();
std::io::stderr().write_all(&stderr).unwrap();
}
std::process::exit(code);
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use ratatui::{backend::TestBackend, Terminal};
fn render_popup(name: &str, popup: SpinPopup<'_>, state: &mut SpinState) {
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 => "spin/snapshots",
prepend_module_to_snapshot => false,
}, {
assert_snapshot!(name, terminal.backend());
});
}
#[test]
fn parse_variant_known() {
assert!(matches!(parse_variant("dots"), SpinVariant::Dots));
assert!(matches!(parse_variant("line"), SpinVariant::Line));
assert!(matches!(parse_variant("dots2"), SpinVariant::Dots2));
assert!(matches!(parse_variant("bounce"), SpinVariant::Bounce));
assert!(matches!(parse_variant("pulse"), SpinVariant::Pulse));
assert!(matches!(parse_variant("arrows"), SpinVariant::Arrows));
assert!(matches!(parse_variant("square"), SpinVariant::Square));
assert!(matches!(parse_variant("clock"), SpinVariant::Clock));
}
#[test]
fn parse_variant_case_insensitive() {
assert!(matches!(parse_variant("DOTS"), SpinVariant::Dots));
assert!(matches!(parse_variant("Line"), SpinVariant::Line));
}
#[test]
fn parse_variant_unknown_defaults_to_dots() {
assert!(matches!(parse_variant("nope"), SpinVariant::Dots));
assert!(matches!(parse_variant(""), SpinVariant::Dots));
}
#[test]
fn resolve_cmd_from_dash_c() {
let r = resolve_cmd(&Some("ls -la /tmp".to_string()), &[]).unwrap();
assert_eq!(r.display, "ls -la /tmp");
assert_eq!(r.program, "ls");
assert_eq!(r.args, vec!["-la", "/tmp"]);
}
#[test]
fn resolve_cmd_from_positional() {
let r = resolve_cmd(&None, &["echo".to_string(), "hello".to_string()]).unwrap();
assert_eq!(r.display, "echo hello");
assert_eq!(r.program, "echo");
assert_eq!(r.args, vec!["hello".to_string()]);
}
#[test]
fn resolve_cmd_dash_c_wins_over_positional() {
let r = resolve_cmd(
&Some("true".to_string()),
&["echo".to_string(), "hi".to_string()],
)
.unwrap();
assert_eq!(r.program, "true");
}
#[test]
fn resolve_cmd_empty_when_nothing_provided() {
assert!(resolve_cmd(&None, &[]).is_none());
}
#[test]
fn snapshot_default() {
let popup = SpinPopup::new().title("Procesando");
let mut state = SpinState::default();
render_popup("spin_default", popup, &mut state);
}
#[test]
fn snapshot_with_command() {
let popup = SpinPopup::new()
.title("Build")
.command("cargo build --release");
let mut state = SpinState::default();
render_popup("spin_with_command", popup, &mut state);
}
#[test]
fn snapshot_finished() {
let popup = SpinPopup::new().title("Hecho");
let mut state = SpinState {
finished: true,
exit_code: Some(0),
..Default::default()
};
render_popup("spin_finished", popup, &mut state);
}
}