use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Style},
widgets::Widget,
};
use crate::tui::theme::{TaskStatus, Theme};
const DEFAULT_PENDING_COLOR: Color = Color::Rgb(107, 114, 128); const DEFAULT_RUNNING_COLOR: Color = Color::Rgb(245, 158, 11); const DEFAULT_SUCCESS_COLOR: Color = Color::Rgb(34, 197, 94); const DEFAULT_FAILED_COLOR: Color = Color::Rgb(239, 68, 68); const DEFAULT_PAUSED_COLOR: Color = Color::Rgb(6, 182, 212);
#[derive(Debug, Clone)]
pub struct TimelineEntry {
pub id: String,
pub status: TaskStatus,
pub duration_ms: Option<u64>,
pub is_current: bool,
pub has_breakpoint: bool,
}
impl TimelineEntry {
pub fn new(id: impl Into<String>, status: TaskStatus) -> Self {
Self {
id: id.into(),
status,
duration_ms: None,
is_current: false,
has_breakpoint: false,
}
}
pub fn with_duration(mut self, ms: u64) -> Self {
self.duration_ms = Some(ms);
self
}
pub fn current(mut self) -> Self {
self.is_current = true;
self
}
pub fn with_breakpoint(mut self, has_bp: bool) -> Self {
self.has_breakpoint = has_bp;
self
}
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub struct Timeline<'a> {
entries: &'a [TimelineEntry],
elapsed_ms: u64,
style: Style,
frame: u8,
theme: Option<&'a Theme>,
}
impl<'a> Timeline<'a> {
pub fn new(entries: &'a [TimelineEntry]) -> Self {
Self {
entries,
elapsed_ms: 0,
style: Style::default(),
frame: 0,
theme: None,
}
}
pub fn with_theme(mut self, theme: &'a Theme) -> Self {
self.theme = Some(theme);
self
}
pub fn elapsed(mut self, ms: u64) -> Self {
self.elapsed_ms = ms;
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn with_frame(mut self, frame: u8) -> Self {
self.frame = frame;
self
}
fn spinner(&self) -> &'static str {
let idx = (self.frame / 6) as usize % SPINNER_FRAMES.len();
SPINNER_FRAMES[idx]
}
fn status_color(&self, status: TaskStatus) -> Color {
match status {
TaskStatus::Queued => self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_PENDING_COLOR),
TaskStatus::Pending => self
.theme
.map(|t| t.status_pending)
.unwrap_or(DEFAULT_PENDING_COLOR),
TaskStatus::Running => self
.theme
.map(|t| t.status_running)
.unwrap_or(DEFAULT_RUNNING_COLOR),
TaskStatus::Success => self
.theme
.map(|t| t.status_success)
.unwrap_or(DEFAULT_SUCCESS_COLOR),
TaskStatus::Failed => self
.theme
.map(|t| t.status_failed)
.unwrap_or(DEFAULT_FAILED_COLOR),
TaskStatus::Paused => self
.theme
.map(|t| t.status_paused)
.unwrap_or(DEFAULT_PAUSED_COLOR),
TaskStatus::Skipped => self
.theme
.map(|t| t.text_muted)
.unwrap_or(DEFAULT_PENDING_COLOR),
}
}
fn status_icon_static(status: TaskStatus, is_current: bool) -> &'static str {
if is_current && status != TaskStatus::Running {
return "◉";
}
match status {
TaskStatus::Queued => "○",
TaskStatus::Pending => "◦",
TaskStatus::Running => "◉", TaskStatus::Success => "●",
TaskStatus::Failed => "⊗",
TaskStatus::Paused => "◎",
TaskStatus::Skipped => "⊘",
}
}
fn status_icon(&self, status: TaskStatus, is_current: bool) -> &str {
if status == TaskStatus::Running {
return self.spinner();
}
Self::status_icon_static(status, is_current)
}
}
impl Widget for Timeline<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 3 || area.width < 10 || self.entries.is_empty() {
return;
}
let muted_color = self.theme.map(|t| t.text_muted).unwrap_or(Color::DarkGray);
let highlight_color = self.theme.map(|t| t.highlight).unwrap_or(Color::Cyan);
let num_entries = self.entries.len();
let available_width = area.width.saturating_sub(2) as usize;
let entry_width = (available_width / num_entries.max(1)).max(3);
let track_y = area.y + 1;
let track_char = "─";
for x in area.x..(area.x + area.width) {
buf.set_string(x, track_y, track_char, Style::default().fg(muted_color));
}
for (i, entry) in self.entries.iter().enumerate() {
let x = area.x + (i * entry_width) as u16 + 1;
if x >= area.x + area.width {
break;
}
let color = self.status_color(entry.status);
let icon = self.status_icon(entry.status, entry.is_current);
if entry.has_breakpoint && area.y > 0 {
buf.set_string(
x,
area.y,
"🔴",
Style::default().fg(self.status_color(TaskStatus::Failed)),
);
}
buf.set_string(x, track_y, icon, Style::default().fg(color));
if area.height > 2 {
let label_y = track_y + 1;
let max_len = entry_width.saturating_sub(1);
let label: String = if entry.id.chars().count() > max_len {
entry.id.chars().take(max_len).collect()
} else {
entry.id.clone()
};
buf.set_string(x, label_y, &label, Style::default().fg(color));
}
}
if area.height > 0 {
let elapsed_str = format_duration(self.elapsed_ms);
let elapsed_x = area.x + area.width.saturating_sub(elapsed_str.len() as u16 + 1);
buf.set_string(
elapsed_x,
area.y,
&elapsed_str,
Style::default().fg(highlight_color),
);
}
}
}
fn format_duration(ms: u64) -> String {
let total_secs = ms / 1000;
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
} else {
format!("{:02}:{:02}", minutes, seconds)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_timeline_entry_creation() {
let entry = TimelineEntry::new("task1", TaskStatus::Running)
.with_duration(500)
.current();
assert_eq!(entry.id, "task1");
assert_eq!(entry.status, TaskStatus::Running);
assert_eq!(entry.duration_ms, Some(500));
assert!(entry.is_current);
}
#[test]
fn test_format_duration() {
assert_eq!(format_duration(0), "00:00");
assert_eq!(format_duration(5000), "00:05");
assert_eq!(format_duration(65000), "01:05");
assert_eq!(format_duration(3661000), "01:01:01");
}
#[test]
fn test_status_colors() {
let entries: &[TimelineEntry] = &[];
let timeline = Timeline::new(entries);
assert_ne!(
timeline.status_color(TaskStatus::Running),
timeline.status_color(TaskStatus::Success)
);
}
#[test]
fn test_timeline_entry_breakpoint() {
let entry = TimelineEntry::new("task1", TaskStatus::Pending);
assert!(!entry.has_breakpoint);
let entry = TimelineEntry::new("task1", TaskStatus::Pending).with_breakpoint(true);
assert!(entry.has_breakpoint);
let entry = TimelineEntry::new("task1", TaskStatus::Pending).with_breakpoint(false);
assert!(!entry.has_breakpoint);
}
}