use std::cell::RefCell;
use std::fs::File;
use std::rc::Rc;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Gauge, Paragraph, Wrap};
use ratatui::Terminal;
use rust_apt::raw::{AcqTextStatus, ItemDesc, PkgAcquire};
use crate::types::PackageInfo;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProgressPhase {
Downloading,
Installing,
Done,
}
pub struct StdioRedirect {
saved_stdout: libc::c_int,
saved_stderr: libc::c_int,
capture_path: std::path::PathBuf,
}
impl StdioRedirect {
pub fn capture() -> std::io::Result<Self> {
use std::os::unix::io::AsRawFd;
let capture_path = std::env::temp_dir().join("synh8-apt-output.tmp");
unsafe {
let saved_stdout = libc::dup(libc::STDOUT_FILENO);
if saved_stdout == -1 {
return Err(std::io::Error::last_os_error());
}
let saved_stderr = libc::dup(libc::STDERR_FILENO);
if saved_stderr == -1 {
libc::close(saved_stdout);
return Err(std::io::Error::last_os_error());
}
let file = match std::fs::File::create(&capture_path) {
Ok(f) => f,
Err(e) => {
libc::close(saved_stdout);
libc::close(saved_stderr);
return Err(e);
}
};
let capture_fd = file.as_raw_fd();
if libc::dup2(capture_fd, libc::STDOUT_FILENO) == -1 {
libc::close(saved_stdout);
libc::close(saved_stderr);
return Err(std::io::Error::last_os_error());
}
if libc::dup2(capture_fd, libc::STDERR_FILENO) == -1 {
libc::dup2(saved_stdout, libc::STDOUT_FILENO);
libc::close(saved_stdout);
libc::close(saved_stderr);
return Err(std::io::Error::last_os_error());
}
Ok(Self { saved_stdout, saved_stderr, capture_path })
}
}
pub fn output(&self) -> Vec<String> {
unsafe { libc::fflush(std::ptr::null_mut()); }
std::fs::read_to_string(&self.capture_path)
.unwrap_or_default()
.lines()
.map(String::from)
.collect()
}
}
impl Drop for StdioRedirect {
fn drop(&mut self) {
unsafe {
let r1 = libc::dup2(self.saved_stdout, libc::STDOUT_FILENO);
debug_assert!(r1 != -1, "dup2 failed restoring stdout");
let r2 = libc::dup2(self.saved_stderr, libc::STDERR_FILENO);
debug_assert!(r2 != -1, "dup2 failed restoring stderr");
libc::close(self.saved_stdout);
libc::close(self.saved_stderr);
}
drop(std::fs::remove_file(&self.capture_path));
}
}
pub struct ProgressSnapshot<'a> {
pub phase: ProgressPhase,
pub percent: f64,
pub current_bytes: u64,
pub total_bytes: u64,
pub speed_bps: u64,
pub install_steps_done: u64,
pub install_total_steps: u64,
pub install_action: &'a str,
pub errors: &'a [String],
pub title: &'a str,
}
pub struct ProgressState {
terminal: Terminal<TermionBackend<File>>,
pub phase: ProgressPhase,
pub percent: f64,
pub current_bytes: u64,
pub total_bytes: u64,
pub speed_bps: u64,
pub install_steps_done: u64,
pub install_total_steps: u64,
pub install_action: String,
pub errors: Vec<String>,
pub title: String,
}
impl ProgressState {
pub fn new(title: &str) -> std::io::Result<Self> {
let tty = std::fs::OpenOptions::new()
.write(true)
.open("/dev/tty")?;
let backend = TermionBackend::new(tty);
let terminal = Terminal::new(backend)?;
Ok(Self {
terminal,
phase: ProgressPhase::Downloading,
percent: 0.0,
current_bytes: 0,
total_bytes: 0,
speed_bps: 0,
install_steps_done: 0,
install_total_steps: 0,
install_action: String::new(),
errors: Vec::new(),
title: title.to_string(),
})
}
fn draw(&mut self) {
let snap = ProgressSnapshot {
phase: self.phase,
percent: self.percent,
current_bytes: self.current_bytes,
total_bytes: self.total_bytes,
speed_bps: self.speed_bps,
install_steps_done: self.install_steps_done,
install_total_steps: self.install_total_steps,
install_action: &self.install_action,
errors: &self.errors,
title: &self.title,
};
let _ = self.terminal.draw(|frame| {
render_progress_modal(frame, &snap);
});
}
}
pub struct TuiAcquireProgress {
state: Rc<RefCell<ProgressState>>,
}
impl TuiAcquireProgress {
pub fn new(state: Rc<RefCell<ProgressState>>) -> Self {
Self { state }
}
}
impl rust_apt::progress::DynAcquireProgress for TuiAcquireProgress {
fn pulse_interval(&self) -> usize {
500_000 }
fn hit(&mut self, _item: &ItemDesc) {}
fn fetch(&mut self, _item: &ItemDesc) {}
fn done(&mut self, _item: &ItemDesc) {}
fn fail(&mut self, item: &ItemDesc) {
let owner = item.owner();
let error_text = owner.error_text();
if !error_text.is_empty() {
let mut state = self.state.borrow_mut();
state.errors.push(format!("{}: {error_text}", item.short_desc()));
state.draw();
}
}
fn pulse(&mut self, status: &AcqTextStatus, _owner: &PkgAcquire) {
let mut state = self.state.borrow_mut();
state.percent = status.percent();
state.current_bytes = status.current_bytes();
state.total_bytes = status.total_bytes();
state.speed_bps = status.current_cps();
state.draw();
}
fn start(&mut self) {
let mut state = self.state.borrow_mut();
state.phase = ProgressPhase::Downloading;
state.draw();
}
fn stop(&mut self, _status: &AcqTextStatus) {
}
}
pub struct TuiInstallProgress {
state: Rc<RefCell<ProgressState>>,
}
impl TuiInstallProgress {
pub fn new(state: Rc<RefCell<ProgressState>>) -> Self {
Self { state }
}
}
impl rust_apt::progress::DynInstallProgress for TuiInstallProgress {
fn status_changed(
&mut self,
pkgname: String,
steps_done: u64,
total_steps: u64,
action: String,
) {
let mut state = self.state.borrow_mut();
state.phase = ProgressPhase::Installing;
state.install_steps_done = steps_done;
state.install_total_steps = total_steps;
state.install_action = if pkgname.is_empty() {
action
} else {
format!("{action} {pkgname}")
};
state.draw();
}
fn error(&mut self, pkgname: String, _steps_done: u64, _total_steps: u64, error: String) {
let mut state = self.state.borrow_mut();
state.errors.push(format!("{pkgname}: {error}"));
state.draw();
}
}
fn render_progress_modal(frame: &mut Frame, snap: &ProgressSnapshot) {
let &ProgressSnapshot {
phase,
percent,
current_bytes,
total_bytes,
speed_bps,
install_steps_done,
install_total_steps,
install_action,
errors,
title,
} = snap;
let area = frame.area();
let modal_width = 70.min(area.width.saturating_sub(4));
let base_height: u16 = 9;
let error_height = if errors.is_empty() {
0
} else {
1 + (errors.len() as u16).min(4)
};
let modal_height = (base_height + error_height).min(area.height.saturating_sub(2));
let modal_x = area.x + (area.width - modal_width) / 2;
let modal_y = area.y + (area.height - modal_height) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
frame.render_widget(Clear, modal_area);
let border_color = match phase {
ProgressPhase::Downloading => Color::Cyan,
ProgressPhase::Installing => Color::Green,
ProgressPhase::Done => Color::Green,
};
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color));
let inner = block.inner(modal_area);
frame.render_widget(block, modal_area);
let mut constraints = vec![
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ];
if error_height > 0 {
constraints.push(Constraint::Min(error_height));
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
match phase {
ProgressPhase::Downloading => {
let speed_str = if speed_bps > 0 {
format!(" {}/s", PackageInfo::size_str(speed_bps))
} else {
String::new()
};
let status = Line::from(vec![
Span::styled("Downloading... ", Style::default().fg(Color::Cyan)),
Span::styled(format!("{percent:.0}%"), Style::default().fg(Color::White).bold()),
Span::styled(speed_str, Style::default().fg(Color::DarkGray)),
]);
frame.render_widget(Paragraph::new(status), chunks[1]);
let ratio = (percent / 100.0).clamp(0.0, 1.0);
let gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Cyan).bg(Color::DarkGray))
.ratio(ratio);
frame.render_widget(gauge, chunks[3]);
let detail = Line::from(Span::styled(
format!(
"{} / {}",
PackageInfo::size_str(current_bytes),
PackageInfo::size_str(total_bytes),
),
Style::default().fg(Color::DarkGray),
));
frame.render_widget(Paragraph::new(detail), chunks[5]);
}
ProgressPhase::Installing => {
let status = Line::from(vec![
Span::styled("Installing... ", Style::default().fg(Color::Green)),
Span::styled(
format!("Step {install_steps_done} / {install_total_steps}"),
Style::default().fg(Color::White).bold(),
),
]);
frame.render_widget(Paragraph::new(status), chunks[1]);
let ratio = if install_total_steps > 0 {
(install_steps_done as f64 / install_total_steps as f64).clamp(0.0, 1.0)
} else {
0.0
};
let gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green).bg(Color::DarkGray))
.ratio(ratio);
frame.render_widget(gauge, chunks[3]);
let detail = Line::from(Span::styled(
install_action,
Style::default().fg(Color::DarkGray),
));
frame.render_widget(Paragraph::new(detail), chunks[5]);
}
ProgressPhase::Done => {
let status = Line::from(Span::styled(
"Complete.",
Style::default().fg(Color::Green).bold(),
));
frame.render_widget(Paragraph::new(status), chunks[1]);
let gauge = Gauge::default()
.gauge_style(Style::default().fg(Color::Green).bg(Color::DarkGray))
.ratio(1.0);
frame.render_widget(gauge, chunks[3]);
}
}
if error_height > 0 && chunks.len() > 7 {
let error_lines: Vec<Line> = errors
.iter()
.rev()
.take(4)
.rev()
.map(|e| Line::from(Span::styled(e.as_str(), Style::default().fg(Color::Red))))
.collect();
let error_para = Paragraph::new(error_lines)
.wrap(Wrap { trim: false });
frame.render_widget(error_para, chunks[7]);
}
}