use ratatui::prelude::*;
use zlayer_tui::icons;
use zlayer_tui::palette::color;
use super::app::InstructionState;
use super::InstructionStatus;
pub struct InstructionList<'a> {
pub instructions: &'a [InstructionState],
pub current: usize,
}
impl Widget for InstructionList<'_> {
#[allow(clippy::cast_possible_truncation)]
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 {
return;
}
let visible_count = area.height as usize;
let total = self.instructions.len();
let (start, end) = if total <= visible_count {
(0, total)
} else {
let half = visible_count / 2;
let start = if self.current < half {
0
} else if self.current + half >= total {
total.saturating_sub(visible_count)
} else {
self.current.saturating_sub(half)
};
let end = (start + visible_count).min(total);
(start, end)
};
for (display_idx, idx) in (start..end).enumerate() {
if display_idx >= area.height as usize {
break;
}
let inst = &self.instructions[idx];
#[allow(clippy::cast_possible_truncation)]
let y = area.y + (display_idx as u16);
let (indicator, indicator_style) = match inst.status {
InstructionStatus::Pending => {
(icons::PENDING, Style::default().fg(color::INACTIVE))
}
InstructionStatus::Running => (
icons::RUNNING,
Style::default()
.fg(color::WARNING)
.add_modifier(Modifier::BOLD),
),
InstructionStatus::Complete { .. } => {
(icons::COMPLETE, Style::default().fg(color::SUCCESS))
}
InstructionStatus::Failed => (icons::FAILED, Style::default().fg(color::ERROR)),
};
buf.set_string(area.x, y, indicator.to_string(), indicator_style);
let text_style = match inst.status {
InstructionStatus::Pending => Style::default().fg(color::INACTIVE),
InstructionStatus::Running => Style::default()
.fg(color::WARNING)
.add_modifier(Modifier::BOLD),
InstructionStatus::Complete { .. } => Style::default().fg(color::TEXT),
InstructionStatus::Failed => Style::default().fg(color::ERROR),
};
let available_width = area.width.saturating_sub(2) as usize; let text = if inst.text.len() > available_width {
format!("{}...", &inst.text[..available_width.saturating_sub(3)])
} else {
inst.text.clone()
};
buf.set_string(area.x + 2, y, &text, text_style);
if let InstructionStatus::Complete { cached: true } = inst.status {
let cached_str = " [cached]";
let text_end = area.x + 2 + text.len() as u16;
let cached_x =
text_end.min(area.x + area.width.saturating_sub(cached_str.len() as u16));
if cached_x + cached_str.len() as u16 <= area.x + area.width {
buf.set_string(
cached_x,
y,
cached_str,
Style::default()
.fg(color::ACCENT)
.add_modifier(Modifier::DIM),
);
}
}
}
if total > visible_count {
let indicator = format!("({}/{})", end.min(total), total);
let x = area.x + area.width.saturating_sub(indicator.len() as u16);
if x >= area.x {
buf.set_string(x, area.y, &indicator, Style::default().fg(color::INACTIVE));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use zlayer_tui::widgets::progress_bar::ProgressBar;
use zlayer_tui::widgets::scrollable_pane::{OutputLine, ScrollablePane};
fn create_buffer(width: u16, height: u16) -> Buffer {
Buffer::empty(Rect::new(0, 0, width, height))
}
#[test]
fn test_progress_bar_full() {
let mut buf = create_buffer(40, 1);
let area = Rect::new(0, 0, 40, 1);
let progress = ProgressBar::new(5, 10).with_label("5/10");
progress.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(!content.trim().is_empty());
}
#[test]
fn test_progress_bar_zero() {
let mut buf = create_buffer(40, 1);
let area = Rect::new(0, 0, 40, 1);
let progress = ProgressBar::new(0, 0).with_label("0/0");
progress.render(area, &mut buf);
}
#[test]
fn test_instruction_list_empty() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let list = InstructionList {
instructions: &[],
current: 0,
};
list.render(area, &mut buf);
}
#[test]
fn test_instruction_list_with_items() {
let mut buf = create_buffer(60, 5);
let area = Rect::new(0, 0, 60, 5);
let instructions = vec![
InstructionState {
text: "WORKDIR /app".to_string(),
status: InstructionStatus::Complete { cached: false },
},
InstructionState {
text: "RUN npm ci".to_string(),
status: InstructionStatus::Running,
},
InstructionState {
text: "COPY . .".to_string(),
status: InstructionStatus::Pending,
},
];
let list = InstructionList {
instructions: &instructions,
current: 1,
};
list.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(content.contains("WORKDIR") || content.contains("RUN") || content.contains("COPY"));
}
#[test]
fn test_instruction_list_cached_tag() {
let mut buf = create_buffer(60, 2);
let area = Rect::new(0, 0, 60, 2);
let instructions = vec![InstructionState {
text: "COPY package.json ./".to_string(),
status: InstructionStatus::Complete { cached: true },
}];
let list = InstructionList {
instructions: &instructions,
current: 0,
};
list.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(content.contains("[cached]"));
}
#[test]
fn test_output_log_empty() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let lines: Vec<OutputLine> = vec![];
let pane = ScrollablePane::new(&lines, 0).with_empty_text("Waiting for output...");
pane.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(content.contains("Waiting"));
}
#[test]
fn test_output_log_with_lines() {
let mut buf = create_buffer(60, 5);
let area = Rect::new(0, 0, 60, 5);
let lines = vec![
OutputLine {
text: "stdout line 1".to_string(),
is_stderr: false,
},
OutputLine {
text: "stderr line 1".to_string(),
is_stderr: true,
},
];
let pane = ScrollablePane::new(&lines, 0);
pane.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(content.contains("stdout") || content.contains("stderr"));
}
#[test]
fn test_output_log_scroll() {
let mut buf = create_buffer(40, 5);
let area = Rect::new(0, 0, 40, 5);
let lines: Vec<OutputLine> = (0..10)
.map(|i| OutputLine {
text: format!("Line {i}"),
is_stderr: false,
})
.collect();
let pane = ScrollablePane::new(&lines, 5);
pane.render(area, &mut buf);
let content = buf
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>();
assert!(
content.contains("Line 5") || content.contains("Line 6") || content.contains("Line 7")
);
}
}