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::{PopupSize, SpinPopup, SpinState, SpinVariant};
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!("../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 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
}
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!("pandora spin: se requiere un comando. Usa -c \"comando\" o -- comando.");
std::process::exit(1);
}
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!("pandora spin: el terminal no es interactivo");
std::process::exit(1);
}
let mut terminal = Terminal::new(CrosstermBackend::new(tty)).unwrap();
terminal.clear().unwrap();
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_offset: Option<(u16, u16)> = None;
let mut origin: Option<(u16, u16)> = None;
let mut pending_g: bool = false;
while !should_exit {
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 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));
if args.width > 0 {
popup = popup.width(PopupSize::Fixed(pw));
}
if args.height > 0 {
popup = popup.height(PopupSize::Fixed(args.height));
}
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 pr = popup.resolve_rect(area);
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;
if 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((dx, dy)) = drag_offset {
match mouse.kind {
MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
let max_w = area.width.saturating_sub(pr.width);
let max_h = area.height.saturating_sub(pr.height);
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;
}
_ => {}
}
}
}
_ => {}
}
}
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);
}
}