use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Paragraph, Widget},
};
use crate::utils::display::{pad_to_width, truncate_to_width};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StepStatus {
#[default]
Pending,
Running,
Completed,
Failed,
Skipped,
}
impl StepStatus {
pub fn icon(&self) -> &'static str {
match self {
StepStatus::Pending => "[ ]",
StepStatus::Running => "[▶]",
StepStatus::Completed => "[✓]",
StepStatus::Failed => "[✗]",
StepStatus::Skipped => "[↷]",
}
}
pub fn color(&self) -> Color {
match self {
StepStatus::Pending => Color::DarkGray,
StepStatus::Running => Color::Yellow,
StepStatus::Completed => Color::Green,
StepStatus::Failed => Color::Red,
StepStatus::Skipped => Color::DarkGray,
}
}
pub fn sub_icon(&self) -> &'static str {
match self {
StepStatus::Pending => "○",
StepStatus::Running => "◐",
StepStatus::Completed => "●",
StepStatus::Failed => "✗",
StepStatus::Skipped => "◌",
}
}
}
#[derive(Debug, Clone)]
pub struct SubStep {
pub name: String,
pub status: StepStatus,
}
impl SubStep {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
status: StepStatus::Pending,
}
}
}
#[derive(Debug, Clone)]
pub struct Step {
pub name: String,
pub status: StepStatus,
pub sub_steps: Vec<SubStep>,
pub expanded: bool,
pub output: Vec<String>,
pub scroll: u16,
}
impl Step {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
status: StepStatus::Pending,
sub_steps: Vec::new(),
expanded: false,
output: Vec::new(),
scroll: 0,
}
}
pub fn with_sub_steps(mut self, names: Vec<&str>) -> Self {
self.sub_steps = names.into_iter().map(SubStep::new).collect();
self
}
pub fn add_output(&mut self, line: impl Into<String>) {
self.output.push(line.into());
let visible_lines = 5;
if self.output.len() > visible_lines {
self.scroll = (self.output.len() - visible_lines) as u16;
}
}
pub fn clear_output(&mut self) {
self.output.clear();
self.scroll = 0;
}
pub fn sub_step_progress(&self) -> (usize, usize) {
let completed = self
.sub_steps
.iter()
.filter(|s| s.status == StepStatus::Completed)
.count();
(completed, self.sub_steps.len())
}
}
#[derive(Debug, Clone)]
pub struct StepDisplayState {
pub steps: Vec<Step>,
pub focused_step: Option<usize>,
pub scroll: u16,
}
impl StepDisplayState {
pub fn new(steps: Vec<Step>) -> Self {
Self {
steps,
focused_step: None,
scroll: 0,
}
}
pub fn progress(&self) -> f64 {
if self.steps.is_empty() {
return 0.0;
}
let completed = self
.steps
.iter()
.filter(|s| s.status == StepStatus::Completed)
.count();
completed as f64 / self.steps.len() as f64
}
pub fn current_step(&self) -> usize {
self.steps
.iter()
.position(|s| s.status != StepStatus::Completed && s.status != StepStatus::Skipped)
.unwrap_or(self.steps.len())
}
pub fn start_step(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Running;
step.expanded = true;
}
}
pub fn complete_step(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Completed;
}
}
pub fn fail_step(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Failed;
}
}
pub fn skip_step(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.status = StepStatus::Skipped;
}
}
pub fn start_sub_step(&mut self, step_index: usize, sub_index: usize) {
if let Some(step) = self.steps.get_mut(step_index) {
if let Some(sub) = step.sub_steps.get_mut(sub_index) {
sub.status = StepStatus::Running;
}
}
}
pub fn complete_sub_step(&mut self, step_index: usize, sub_index: usize) {
if let Some(step) = self.steps.get_mut(step_index) {
if let Some(sub) = step.sub_steps.get_mut(sub_index) {
sub.status = StepStatus::Completed;
}
}
}
pub fn add_output(&mut self, step_index: usize, line: impl Into<String>) {
if let Some(step) = self.steps.get_mut(step_index) {
step.add_output(line);
}
}
pub fn toggle_expanded(&mut self, index: usize) {
if let Some(step) = self.steps.get_mut(index) {
step.expanded = !step.expanded;
}
}
pub fn scroll_output(&mut self, index: usize, delta: i32) {
if let Some(step) = self.steps.get_mut(index) {
let max_scroll = step.output.len().saturating_sub(5) as i32;
let new_scroll = (step.scroll as i32 + delta).clamp(0, max_scroll);
step.scroll = new_scroll as u16;
}
}
}
#[derive(Debug, Clone)]
pub struct StepDisplayStyle {
pub focused_border: Color,
pub unfocused_border: Color,
pub max_output_lines: usize,
}
impl Default for StepDisplayStyle {
fn default() -> Self {
Self {
focused_border: Color::Cyan,
unfocused_border: Color::DarkGray,
max_output_lines: 5,
}
}
}
impl From<&crate::theme::Theme> for StepDisplayStyle {
fn from(theme: &crate::theme::Theme) -> Self {
let p = &theme.palette;
Self {
focused_border: p.border_accent,
unfocused_border: p.border_disabled,
max_output_lines: 5,
}
}
}
pub struct StepDisplay<'a> {
state: &'a StepDisplayState,
style: StepDisplayStyle,
}
impl<'a> StepDisplay<'a> {
pub fn new(state: &'a StepDisplayState) -> Self {
Self {
state,
style: StepDisplayStyle::default(),
}
}
pub fn style(mut self, style: StepDisplayStyle) -> Self {
self.style = style;
self
}
pub fn theme(self, theme: &crate::theme::Theme) -> Self {
self.style(StepDisplayStyle::from(theme))
}
fn build_lines(&self, area: Rect) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let full_width = area.width as usize;
for (idx, step) in self.state.steps.iter().enumerate() {
let icon_color = step.status.color();
let step_style = match step.status {
StepStatus::Running => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
StepStatus::Failed => Style::default().fg(Color::Red),
StepStatus::Completed => Style::default().fg(Color::Green),
_ => Style::default().fg(Color::White),
};
let header_suffix = if !step.sub_steps.is_empty() {
let (completed, total) = step.sub_step_progress();
format!(" ({}/{})", completed, total)
} else {
String::new()
};
lines.push(Line::from(vec![
Span::styled(
format!("{} ", step.status.icon()),
Style::default().fg(icon_color),
),
Span::styled(format!("Step {}: ", idx + 1), step_style),
Span::styled(step.name.clone(), step_style),
Span::styled(header_suffix, Style::default().fg(Color::DarkGray)),
]));
if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
for sub in &step.sub_steps {
let sub_color = sub.status.color();
let sub_style = match sub.status {
StepStatus::Running => Style::default().fg(Color::Yellow),
StepStatus::Completed => Style::default().fg(Color::Green),
StepStatus::Failed => Style::default().fg(Color::Red),
StepStatus::Skipped => Style::default().fg(Color::DarkGray),
_ => Style::default().fg(Color::White),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{} ", sub.status.sub_icon()),
Style::default().fg(sub_color),
),
Span::styled(sub.name.clone(), sub_style),
]));
}
}
if step.expanded && !step.output.is_empty() {
let is_focused = self.state.focused_step == Some(idx);
let border_color = if is_focused {
self.style.focused_border
} else {
self.style.unfocused_border
};
let border_width = full_width.saturating_sub(6);
let content_width = full_width.saturating_sub(8);
lines.push(Line::from(Span::styled(
format!(" ┌{:─<width$}┐ ", " Output ", width = border_width),
Style::default().fg(border_color),
)));
let visible_lines = self.style.max_output_lines;
let scroll = step.scroll as usize;
let total = step.output.len();
for i in 0..visible_lines {
let line_idx = scroll + i;
let content = if line_idx < total {
truncate_to_width(&step.output[line_idx], content_width)
} else {
String::new()
};
let padded = pad_to_width(&content, content_width);
lines.push(Line::from(vec![
Span::styled(" │ ", Style::default().fg(border_color)),
Span::styled(padded, Style::default().fg(Color::Gray)),
Span::styled(" │ ", Style::default().fg(border_color)),
]));
}
let scroll_info = if total > visible_lines {
format!(" [{}/{} lines] ", scroll + visible_lines.min(total), total)
} else {
String::new()
};
lines.push(Line::from(Span::styled(
format!(" └{:─<width$}┘ ", scroll_info, width = border_width),
Style::default().fg(border_color),
)));
lines.push(Line::from(""));
}
}
lines
}
}
impl Widget for StepDisplay<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
let lines = self.build_lines(area);
let para = Paragraph::new(lines).scroll((self.state.scroll, 0));
para.render(area, buf);
}
}
pub fn calculate_height(state: &StepDisplayState, style: &StepDisplayStyle) -> u16 {
let mut height = 0u16;
for step in &state.steps {
height += 1;
if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
height += step.sub_steps.len() as u16;
}
if step.expanded && !step.output.is_empty() {
height += 2; height += style.max_output_lines as u16;
height += 1; }
}
height
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_step_status_icons() {
assert_eq!(StepStatus::Pending.icon(), "[ ]");
assert_eq!(StepStatus::Running.icon(), "[▶]");
assert_eq!(StepStatus::Completed.icon(), "[✓]");
assert_eq!(StepStatus::Failed.icon(), "[✗]");
assert_eq!(StepStatus::Skipped.icon(), "[↷]");
}
#[test]
fn test_step_status_sub_icons() {
assert_eq!(StepStatus::Pending.sub_icon(), "○");
assert_eq!(StepStatus::Running.sub_icon(), "◐");
assert_eq!(StepStatus::Completed.sub_icon(), "●");
assert_eq!(StepStatus::Failed.sub_icon(), "✗");
assert_eq!(StepStatus::Skipped.sub_icon(), "◌");
}
#[test]
fn test_step_status_colors() {
assert_eq!(StepStatus::Pending.color(), Color::DarkGray);
assert_eq!(StepStatus::Running.color(), Color::Yellow);
assert_eq!(StepStatus::Completed.color(), Color::Green);
assert_eq!(StepStatus::Failed.color(), Color::Red);
assert_eq!(StepStatus::Skipped.color(), Color::DarkGray);
}
#[test]
fn test_step_new() {
let step = Step::new("Build");
assert_eq!(step.name, "Build");
assert_eq!(step.status, StepStatus::Pending);
assert!(step.sub_steps.is_empty());
assert!(!step.expanded);
assert!(step.output.is_empty());
}
#[test]
fn test_step_with_sub_steps() {
let step = Step::new("Build").with_sub_steps(vec!["Compile", "Link", "Package"]);
assert_eq!(step.sub_steps.len(), 3);
assert_eq!(step.sub_steps[0].name, "Compile");
assert_eq!(step.sub_steps[1].name, "Link");
assert_eq!(step.sub_steps[2].name, "Package");
}
#[test]
fn test_step_progress() {
let step = Step::new("Test").with_sub_steps(vec!["A", "B", "C"]);
let (completed, total) = step.sub_step_progress();
assert_eq!(completed, 0);
assert_eq!(total, 3);
}
#[test]
fn test_step_add_output() {
let mut step = Step::new("Test");
step.add_output("Line 1");
step.add_output("Line 2");
assert_eq!(step.output.len(), 2);
assert_eq!(step.output[0], "Line 1");
}
#[test]
fn test_step_clear_output() {
let mut step = Step::new("Test");
step.add_output("Line 1");
step.add_output("Line 2");
step.scroll = 1;
step.clear_output();
assert!(step.output.is_empty());
assert_eq!(step.scroll, 0);
}
#[test]
fn test_step_auto_scroll() {
let mut step = Step::new("Test");
for i in 0..10 {
step.add_output(format!("Line {}", i));
}
assert!(step.scroll > 0);
}
#[test]
fn test_sub_step_new() {
let sub = SubStep::new("Compile");
assert_eq!(sub.name, "Compile");
assert_eq!(sub.status, StepStatus::Pending);
}
#[test]
fn test_state_new() {
let steps = vec![Step::new("Step 1"), Step::new("Step 2")];
let state = StepDisplayState::new(steps);
assert_eq!(state.steps.len(), 2);
assert!(state.focused_step.is_none());
assert_eq!(state.scroll, 0);
}
#[test]
fn test_state_progress() {
let steps = vec![
Step::new("Step 1"),
Step::new("Step 2"),
Step::new("Step 3"),
Step::new("Step 4"),
];
let mut state = StepDisplayState::new(steps);
assert_eq!(state.progress(), 0.0);
state.complete_step(0);
assert!((state.progress() - 0.25).abs() < 0.01);
state.complete_step(1);
assert!((state.progress() - 0.5).abs() < 0.01);
}
#[test]
fn test_state_progress_empty() {
let state = StepDisplayState::new(vec![]);
assert_eq!(state.progress(), 0.0);
}
#[test]
fn test_state_current_step() {
let steps = vec![
Step::new("Step 1"),
Step::new("Step 2"),
Step::new("Step 3"),
];
let mut state = StepDisplayState::new(steps);
assert_eq!(state.current_step(), 0);
state.complete_step(0);
assert_eq!(state.current_step(), 1);
state.skip_step(1);
assert_eq!(state.current_step(), 2);
}
#[test]
fn test_state_operations() {
let steps = vec![Step::new("Test")];
let mut state = StepDisplayState::new(steps);
state.start_step(0);
assert_eq!(state.steps[0].status, StepStatus::Running);
assert!(state.steps[0].expanded);
state.add_output(0, "Line 1");
assert_eq!(state.steps[0].output.len(), 1);
state.complete_step(0);
assert_eq!(state.steps[0].status, StepStatus::Completed);
}
#[test]
fn test_state_fail_step() {
let steps = vec![Step::new("Test")];
let mut state = StepDisplayState::new(steps);
state.fail_step(0);
assert_eq!(state.steps[0].status, StepStatus::Failed);
}
#[test]
fn test_state_skip_step() {
let steps = vec![Step::new("Test")];
let mut state = StepDisplayState::new(steps);
state.skip_step(0);
assert_eq!(state.steps[0].status, StepStatus::Skipped);
}
#[test]
fn test_state_sub_step_operations() {
let steps = vec![Step::new("Test").with_sub_steps(vec!["A", "B"])];
let mut state = StepDisplayState::new(steps);
state.start_sub_step(0, 0);
assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Running);
state.complete_sub_step(0, 0);
assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Completed);
}
#[test]
fn test_state_toggle_expanded() {
let steps = vec![Step::new("Test")];
let mut state = StepDisplayState::new(steps);
assert!(!state.steps[0].expanded);
state.toggle_expanded(0);
assert!(state.steps[0].expanded);
state.toggle_expanded(0);
assert!(!state.steps[0].expanded);
}
#[test]
fn test_state_scroll_output() {
let mut step = Step::new("Test");
for i in 0..20 {
step.add_output(format!("Line {}", i));
}
let steps = vec![step];
let mut state = StepDisplayState::new(steps);
state.steps[0].scroll = 0;
state.scroll_output(0, 5);
assert_eq!(state.steps[0].scroll, 5);
state.scroll_output(0, -3);
assert_eq!(state.steps[0].scroll, 2);
state.scroll_output(0, -10);
assert_eq!(state.steps[0].scroll, 0);
}
#[test]
fn test_state_invalid_index() {
let steps = vec![Step::new("Test")];
let mut state = StepDisplayState::new(steps);
state.start_step(10);
state.complete_step(10);
state.fail_step(10);
state.skip_step(10);
state.add_output(10, "test");
state.toggle_expanded(10);
state.scroll_output(10, 5);
state.start_sub_step(10, 0);
state.complete_sub_step(10, 0);
}
#[test]
fn test_step_display_style_default() {
let style = StepDisplayStyle::default();
assert_eq!(style.focused_border, Color::Cyan);
assert_eq!(style.unfocused_border, Color::DarkGray);
assert_eq!(style.max_output_lines, 5);
}
#[test]
fn test_calculate_height() {
let steps = vec![
Step::new("Step 1"),
Step::new("Step 2").with_sub_steps(vec!["A", "B"]),
];
let mut state = StepDisplayState::new(steps);
let style = StepDisplayStyle::default();
let height = calculate_height(&state, &style);
assert_eq!(height, 2);
state.start_step(1);
let height = calculate_height(&state, &style);
assert!(height > 2); }
#[test]
fn test_step_display_render() {
let steps = vec![
Step::new("Build").with_sub_steps(vec!["Compile", "Link"]),
Step::new("Test"),
];
let state = StepDisplayState::new(steps);
let display = StepDisplay::new(&state);
let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
display.render(Rect::new(0, 0, 60, 20), &mut buf);
}
}