use rusty_stack::installer::InstallerEvent;
use std::io::{IsTerminal, Stdout};
#[cfg(feature = "tui")]
use ratatui::{
backend::CrosstermBackend,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame, Terminal,
};
const SPINNER: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
pub fn spinner_char(frame: usize) -> char {
SPINNER[frame % SPINNER.len()]
}
fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let c = v * s;
let hh = (h * 6.0) % 6.0;
let x = c * (1.0 - (hh % 2.0 - 1.0).abs());
let m = v - c;
let (r1, g1, b1) = match hh {
q if q < 1.0 => (c, x, 0.0),
q if q < 2.0 => (x, c, 0.0),
q if q < 3.0 => (0.0, c, x),
q if q < 4.0 => (0.0, x, c),
q if q < 5.0 => (x, 0.0, c),
_ => (c, 0.0, x),
};
(
((r1 + m) * 255.0).round() as u8,
((g1 + m) * 255.0).round() as u8,
((b1 + m) * 255.0).round() as u8,
)
}
#[cfg(feature = "tui")]
pub fn frame_color(frame: usize) -> Color {
let hue = ((frame as f32 * 12.0) % 360.0) / 360.0;
let (r, g, b) = hsv_to_rgb(hue, 0.65, 1.0);
Color::Rgb(r, g, b)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RowStatus {
#[default]
Pending,
Running,
Done,
Failed,
}
impl RowStatus {
pub fn glyph(self) -> &'static str {
match self {
RowStatus::Pending => " ",
RowStatus::Running => "⠋",
RowStatus::Done => "✓",
RowStatus::Failed => "✗",
}
}
#[cfg(feature = "tui")]
pub fn color(self) -> Color {
match self {
RowStatus::Pending => Color::DarkGray,
RowStatus::Running => Color::Yellow,
RowStatus::Done => Color::Green,
RowStatus::Failed => Color::Red,
}
}
}
#[derive(Debug, Clone)]
pub struct ApplyRow {
pub id: String,
pub name: String,
pub current: String,
pub target: String,
pub status: RowStatus,
pub progress: f32,
pub message: String,
}
impl ApplyRow {
pub fn new(id: &str, name: &str, current: &str, target: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
current: current.to_string(),
target: target.to_string(),
status: RowStatus::Pending,
progress: 0.0,
message: String::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ApplyState {
pub rows: Vec<ApplyRow>,
pub ticker: Vec<String>,
pub title: String,
pub spinner_frame: usize,
}
const TICKER_LEN: usize = 2;
impl ApplyState {
pub fn from_plan<I, S>(title: &str, items: I) -> Self
where
I: IntoIterator<Item = (S, S, S, S)>,
S: AsRef<str>,
{
let rows = items
.into_iter()
.map(|(id, name, current, target)| {
ApplyRow::new(
id.as_ref(),
name.as_ref(),
current.as_ref(),
target.as_ref(),
)
})
.collect();
Self {
rows,
ticker: Vec::new(),
title: title.to_string(),
spinner_frame: 0,
}
}
pub fn apply_event(&mut self, ev: &InstallerEvent, running_id: &str) {
match ev {
InstallerEvent::ComponentStart { component_id, name } => {
if let Some(row) = self.rows.iter_mut().find(|r| r.id == *component_id) {
row.status = RowStatus::Running;
row.progress = 0.0;
row.name = name.clone();
row.message = format!("Installing {}…", name);
}
}
InstallerEvent::Progress {
progress, message, ..
} => {
if let Some(row) = self.rows.iter_mut().find(|r| r.id == running_id) {
row.progress = progress.clamp(0.0, 1.0);
if !message.is_empty() {
row.message = message.clone();
}
row.status = RowStatus::Running;
}
}
InstallerEvent::Log(line, important) => {
let milestone = is_milestone(line);
if milestone {
if let Some(row) = self.rows.iter_mut().find(|r| r.id == running_id) {
row.message = line.clone();
}
}
if milestone || *important {
self.push_ticker(line.clone());
}
}
InstallerEvent::ComponentComplete {
component_id,
success,
message,
} => {
if let Some(row) = self.rows.iter_mut().find(|r| r.id == *component_id) {
row.status = if *success {
RowStatus::Done
} else {
RowStatus::Failed
};
row.progress = if *success { 1.0 } else { row.progress };
row.message = message.clone();
}
}
InstallerEvent::VerificationReport {
component_id,
lines,
} => {
if let Some(row) = self.rows.iter_mut().find(|r| r.id == *component_id) {
if let Some(last) = lines.last() {
row.message = last.clone();
}
}
}
InstallerEvent::Finished { .. } => {}
}
}
fn push_ticker(&mut self, line: String) {
let clean = line.trim().to_string();
if clean.is_empty() {
return;
}
self.ticker.push(clean);
if self.ticker.len() > TICKER_LEN {
self.ticker.drain(0..self.ticker.len() - TICKER_LEN);
}
}
pub fn tally(&self) -> (usize, usize, usize, usize) {
let mut done = 0;
let mut failed = 0;
let mut running = 0;
let mut pending = 0;
for r in &self.rows {
match r.status {
RowStatus::Done => done += 1,
RowStatus::Failed => failed += 1,
RowStatus::Running => running += 1,
RowStatus::Pending => pending += 1,
}
}
(done, failed, running, pending)
}
}
fn is_milestone(line: &str) -> bool {
let l = line.to_lowercase();
l.contains("successfully installed")
|| l.contains("already installed")
|| l.contains("installed ")
|| l.contains("error")
|| l.contains("fail")
|| l.contains("verif")
|| l.contains("pulling")
|| l.contains("cloning")
|| l.contains("version")
|| l.contains("%)")
}
fn progress_bar(p: f32) -> String {
let filled = ((p.clamp(0.0, 1.0) * 10.0).round() as usize).min(10);
let mut s = String::with_capacity(10);
for i in 0..10 {
s.push(if i < filled { '█' } else { '░' });
}
s
}
fn truncate_to_width(s: &str, width: usize) -> String {
if width == 0 {
return String::new();
}
if s.chars().count() <= width {
return s.to_string();
}
let mut out: String = s.chars().take(width.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(feature = "tui")]
pub fn render(frame: &mut Frame<'_>, area: Rect, state: &ApplyState) {
let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).split(area);
let panel = chunks[0];
let footer = chunks[1];
let name_w = state
.rows
.iter()
.map(|r| r.name.len())
.max()
.unwrap_or(8)
.max(8);
let mut lines: Vec<Line> = Vec::with_capacity(state.rows.len() + 1);
lines.push(Line::styled(
format!(
" {:<name_w$} {:>14} {:>14}",
"COMPONENT",
"CURRENT",
"TARGET",
name_w = name_w,
),
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Gray),
));
for r in &state.rows {
let pct = ((r.progress * 100.0) as u16).min(100);
let glyph_text = if r.status == RowStatus::Running {
spinner_char(state.spinner_frame).to_string()
} else {
r.status.glyph().to_string()
};
let glyph_color = if r.status == RowStatus::Running {
frame_color(state.spinner_frame)
} else {
r.status.color()
};
let glyph = Span::styled(
format!("{} ", glyph_text),
Style::default()
.fg(glyph_color)
.add_modifier(Modifier::BOLD),
);
let name = Span::styled(
format!("{:<name_w$} ", r.name, name_w = name_w),
Style::default().fg(if r.status == RowStatus::Running {
Color::Yellow
} else {
Color::White
}),
);
let cur = Span::raw(format!(
"{:>14} ",
if r.current.is_empty() {
"—"
} else {
&r.current
}
));
let arrow = Span::styled("→ ", Style::default().fg(Color::DarkGray));
let tgt = Span::raw(format!(
"{:>14} ",
if r.target.is_empty() {
"—"
} else {
&r.target
}
));
let msg = if r.status == RowStatus::Running {
format!("{} {:>3}%", progress_bar(r.progress), pct)
} else if r.message.is_empty() {
format!("{:>3}%", pct)
} else {
r.message.clone()
};
let msg_w = (panel.width as usize).saturating_sub(name_w + 38).max(8);
let msg = truncate_to_width(&msg, msg_w);
let msg_span = Span::styled(
msg,
Style::default().fg(if r.status == RowStatus::Failed {
Color::Red
} else {
Color::DarkGray
}),
);
lines.push(Line::from(vec![glyph, name, cur, arrow, tgt, msg_span]));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" {} ", state.title))
.title_alignment(Alignment::Left)
.border_style(Style::default().fg(frame_color(state.spinner_frame)));
let para = Paragraph::new(lines).block(block);
frame.render_widget(para, panel);
let (done, failed, running, pending) = state.tally();
let tally_line = Line::from(vec![
Span::styled(format!(" {done} ok",), Style::default().fg(Color::Green)),
Span::raw(" · "),
Span::styled(format!("{failed} failed"), Style::default().fg(Color::Red)),
Span::raw(" · "),
Span::styled(
format!("{running} in flight"),
Style::default().fg(Color::Yellow),
),
Span::raw(" · "),
Span::raw(format!("{pending} pending")),
Span::raw(" [logs: ~/.mlstack/logs]"),
]);
frame.render_widget(
Paragraph::new(tally_line).alignment(Alignment::Left),
footer,
);
}
#[cfg(feature = "tui")]
pub struct ApplyPanel {
terminal: Terminal<CrosstermBackend<Stdout>>,
state: ApplyState,
enabled: bool,
}
#[cfg(feature = "tui")]
impl ApplyPanel {
pub fn try_new(title: &str, rows: Vec<(&str, &str, &str, &str)>) -> Option<Self> {
if !std::io::stdout().is_terminal() {
return None;
}
let height = (rows.len() as u16 + 4).min(40); let backend = CrosstermBackend::new(std::io::stdout());
let terminal = Terminal::with_options(
backend,
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(height),
},
)
.ok()?;
crossterm::terminal::enable_raw_mode().ok()?;
let state = ApplyState::from_plan(title, rows.iter().map(|(a, b, c, d)| (*a, *b, *c, *d)));
Some(Self {
terminal,
state,
enabled: true,
})
}
pub fn state_mut(&mut self) -> &mut ApplyState {
&mut self.state
}
pub fn draw(&mut self) {
if !self.enabled {
return;
}
let _ = self.terminal.draw(|f| {
let area = f.area();
render(f, area, &self.state);
});
}
pub fn tick(&mut self) {
if !self.enabled {
return;
}
self.state.spinner_frame = self.state.spinner_frame.wrapping_add(1);
self.draw();
}
}
#[cfg(feature = "tui")]
impl Drop for ApplyPanel {
fn drop(&mut self) {
if self.enabled {
let _ = self.terminal.draw(|f| {
render(f, f.area(), &self.state);
});
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);
self.enabled = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ev_progress(progress: f32, msg: &str) -> InstallerEvent {
InstallerEvent::Progress {
component_id: "onnx".into(),
progress,
message: msg.into(),
}
}
#[test]
fn row_status_glyphs() {
assert_eq!(RowStatus::Pending.glyph(), " ");
assert_eq!(RowStatus::Running.glyph(), "⠋");
assert_eq!(RowStatus::Done.glyph(), "✓");
assert_eq!(RowStatus::Failed.glyph(), "✗");
}
#[test]
fn state_starts_all_pending() {
let s = ApplyState::from_plan(
"Update",
[
("onnx", "ONNX", "1.23", "1.25"),
("aiter", "AITER", "0.0", "0.1"),
],
);
assert_eq!(s.rows.len(), 2);
assert!(s.rows.iter().all(|r| r.status == RowStatus::Pending));
assert_eq!(s.tally(), (0, 0, 0, 2));
}
#[test]
fn start_then_progress_marks_running() {
let mut s = ApplyState::from_plan("Update", [("onnx", "ONNX", "1.23", "1.25")]);
s.apply_event(
&InstallerEvent::ComponentStart {
component_id: "onnx".into(),
name: "ONNX Runtime".into(),
},
"onnx",
);
assert_eq!(s.rows[0].status, RowStatus::Running);
s.apply_event(&ev_progress(0.5, "downloading"), "onnx");
assert_eq!(s.rows[0].progress, 0.5);
assert_eq!(s.rows[0].message, "downloading");
}
#[test]
fn complete_marks_done_or_failed_and_clamps_progress() {
let mut s = ApplyState::from_plan("U", [("onnx", "ONNX", "1.23", "1.25")]);
s.apply_event(&ev_progress(2.0, "x"), "onnx"); assert_eq!(s.rows[0].progress, 1.0);
s.apply_event(
&InstallerEvent::ComponentComplete {
component_id: "onnx".into(),
success: true,
message: "ok".into(),
},
"onnx",
);
assert_eq!(s.rows[0].status, RowStatus::Done);
assert_eq!(s.tally(), (1, 0, 0, 0));
let mut s2 = ApplyState::from_plan("U", [("onnx", "ONNX", "1.23", "1.25")]);
s2.apply_event(
&InstallerEvent::ComponentComplete {
component_id: "onnx".into(),
success: false,
message: "boom".into(),
},
"onnx",
);
assert_eq!(s2.rows[0].status, RowStatus::Failed);
assert_eq!(s2.tally(), (0, 1, 0, 0));
}
#[test]
fn milestone_log_updates_message_pip_chatter_does_not() {
let mut s = ApplyState::from_plan("U", [("onnx", "ONNX", "1.23", "1.25")]);
s.apply_event(
&InstallerEvent::ComponentStart {
component_id: "onnx".into(),
name: "ONNX Runtime".into(),
},
"onnx",
);
s.apply_event(
&InstallerEvent::Log("Collecting numpy<2".into(), false),
"onnx",
);
assert_eq!(s.rows[0].message, "Installing ONNX Runtime…");
assert!(s.ticker.is_empty());
s.apply_event(
&InstallerEvent::Log(
"Successfully installed onnxruntime-migraphx-1.25.0".into(),
false,
),
"onnx",
);
assert_eq!(
s.rows[0].message,
"Successfully installed onnxruntime-migraphx-1.25.0"
);
assert_eq!(s.ticker.len(), 1);
}
#[test]
fn important_log_always_tickered() {
let mut s = ApplyState::from_plan("U", [("onnx", "ONNX", "1.23", "1.25")]);
s.apply_event(
&InstallerEvent::Log("[ERROR] ONNX Runtime failed: 404".into(), true),
"onnx",
);
assert_eq!(s.ticker.len(), 1);
assert!(s.ticker[0].contains("404"));
}
#[test]
fn ticker_caps_at_two_lines() {
let mut s = ApplyState::from_plan("U", [("onnx", "ONNX", "1.23", "1.25")]);
for i in 0..5 {
s.apply_event(
&InstallerEvent::Log(format!("Successfully installed pkg-{i}"), true),
"onnx",
);
}
assert_eq!(s.ticker.len(), 2);
assert_eq!(s.ticker[1], "Successfully installed pkg-4");
}
#[test]
fn progress_bar_shapes() {
assert_eq!(progress_bar(0.0), "░░░░░░░░░░");
assert_eq!(progress_bar(1.0), "██████████");
assert_eq!(progress_bar(0.5), "█████░░░░░");
}
#[test]
fn truncate_to_width_fits_and_cuts() {
assert_eq!(truncate_to_width("ok", 10), "ok");
assert_eq!(truncate_to_width("abcd", 4), "abcd");
let long = "PyTorch with ROCm is a sealed CORE component — force ignored";
let t = truncate_to_width(long, 20);
assert_eq!(t.chars().count(), 20);
assert!(t.ends_with('…'));
assert!(t.starts_with("PyTorch with ROCm"));
assert_eq!(truncate_to_width("x", 0), "");
}
#[test]
fn is_milestone_filters_pip_chatter() {
assert!(is_milestone(
"Successfully installed onnxruntime-migraphx-1.25.0"
));
assert!(is_milestone("ERROR: Could not install"));
assert!(!is_milestone("Collecting numpy"));
assert!(!is_milestone(" Downloading numpy-2.5.1.whl"));
}
#[test]
fn spinner_char_cycles_through_frames() {
let len = SPINNER.len();
assert_ne!(spinner_char(0), spinner_char(1));
assert_ne!(spinner_char(1), spinner_char(2));
assert_eq!(spinner_char(0), spinner_char(len));
assert_eq!(spinner_char(7), spinner_char(7 + 3 * len));
}
#[test]
fn hsv_to_rgb_stays_in_byte_range_and_extremes() {
let (r, g, b) = hsv_to_rgb(0.0, 1.0, 1.0);
assert_eq!((r, g, b), (255, 0, 0));
let (r, g, b) = hsv_to_rgb(1.0 / 3.0, 1.0, 1.0);
assert_eq!((r, g, b), (0, 255, 0));
let (r, g, b) = hsv_to_rgb(2.0 / 3.0, 1.0, 1.0);
assert_eq!((r, g, b), (0, 0, 255));
let distinct: std::collections::HashSet<(u8, u8, u8)> = (0..30)
.map(|f| {
let h = ((f as f32 * 12.0) % 360.0) / 360.0;
hsv_to_rgb(h, 0.65, 1.0)
})
.collect();
assert!(distinct.len() > 5, "gradient produced too few colors");
}
}