1use ratatui::{
28 buffer::Buffer,
29 layout::Rect,
30 style::{Color, Modifier, Style},
31 text::{Line, Span},
32 widgets::{Paragraph, Widget},
33};
34
35use crate::utils::display::{pad_to_width, truncate_to_width};
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum StepStatus {
40 #[default]
42 Pending,
43 Running,
45 Completed,
47 Failed,
49 Skipped,
51}
52
53impl StepStatus {
54 pub fn icon(&self) -> &'static str {
56 match self {
57 StepStatus::Pending => "[ ]",
58 StepStatus::Running => "[▶]",
59 StepStatus::Completed => "[✓]",
60 StepStatus::Failed => "[✗]",
61 StepStatus::Skipped => "[↷]",
62 }
63 }
64
65 pub fn color(&self) -> Color {
67 match self {
68 StepStatus::Pending => Color::DarkGray,
69 StepStatus::Running => Color::Yellow,
70 StepStatus::Completed => Color::Green,
71 StepStatus::Failed => Color::Red,
72 StepStatus::Skipped => Color::DarkGray,
73 }
74 }
75
76 pub fn sub_icon(&self) -> &'static str {
78 match self {
79 StepStatus::Pending => "○",
80 StepStatus::Running => "◐",
81 StepStatus::Completed => "●",
82 StepStatus::Failed => "✗",
83 StepStatus::Skipped => "◌",
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
90pub struct SubStep {
91 pub name: String,
93 pub status: StepStatus,
95}
96
97impl SubStep {
98 pub fn new(name: impl Into<String>) -> Self {
100 Self {
101 name: name.into(),
102 status: StepStatus::Pending,
103 }
104 }
105}
106
107#[derive(Debug, Clone)]
109pub struct Step {
110 pub name: String,
112 pub status: StepStatus,
114 pub sub_steps: Vec<SubStep>,
116 pub expanded: bool,
118 pub output: Vec<String>,
120 pub scroll: u16,
122}
123
124impl Step {
125 pub fn new(name: impl Into<String>) -> Self {
127 Self {
128 name: name.into(),
129 status: StepStatus::Pending,
130 sub_steps: Vec::new(),
131 expanded: false,
132 output: Vec::new(),
133 scroll: 0,
134 }
135 }
136
137 pub fn with_sub_steps(mut self, names: Vec<&str>) -> Self {
139 self.sub_steps = names.into_iter().map(SubStep::new).collect();
140 self
141 }
142
143 pub fn add_output(&mut self, line: impl Into<String>) {
145 self.output.push(line.into());
146 let visible_lines = 5;
148 if self.output.len() > visible_lines {
149 self.scroll = (self.output.len() - visible_lines) as u16;
150 }
151 }
152
153 pub fn clear_output(&mut self) {
155 self.output.clear();
156 self.scroll = 0;
157 }
158
159 pub fn sub_step_progress(&self) -> (usize, usize) {
161 let completed = self
162 .sub_steps
163 .iter()
164 .filter(|s| s.status == StepStatus::Completed)
165 .count();
166 (completed, self.sub_steps.len())
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct StepDisplayState {
173 pub steps: Vec<Step>,
175 pub focused_step: Option<usize>,
177 pub scroll: u16,
179}
180
181impl StepDisplayState {
182 pub fn new(steps: Vec<Step>) -> Self {
184 Self {
185 steps,
186 focused_step: None,
187 scroll: 0,
188 }
189 }
190
191 pub fn progress(&self) -> f64 {
193 if self.steps.is_empty() {
194 return 0.0;
195 }
196 let completed = self
197 .steps
198 .iter()
199 .filter(|s| s.status == StepStatus::Completed)
200 .count();
201 completed as f64 / self.steps.len() as f64
202 }
203
204 pub fn current_step(&self) -> usize {
206 self.steps
207 .iter()
208 .position(|s| s.status != StepStatus::Completed && s.status != StepStatus::Skipped)
209 .unwrap_or(self.steps.len())
210 }
211
212 pub fn start_step(&mut self, index: usize) {
214 if let Some(step) = self.steps.get_mut(index) {
215 step.status = StepStatus::Running;
216 step.expanded = true;
217 }
218 }
219
220 pub fn complete_step(&mut self, index: usize) {
222 if let Some(step) = self.steps.get_mut(index) {
223 step.status = StepStatus::Completed;
224 }
225 }
226
227 pub fn fail_step(&mut self, index: usize) {
229 if let Some(step) = self.steps.get_mut(index) {
230 step.status = StepStatus::Failed;
231 }
232 }
233
234 pub fn skip_step(&mut self, index: usize) {
236 if let Some(step) = self.steps.get_mut(index) {
237 step.status = StepStatus::Skipped;
238 }
239 }
240
241 pub fn start_sub_step(&mut self, step_index: usize, sub_index: usize) {
243 if let Some(step) = self.steps.get_mut(step_index) {
244 if let Some(sub) = step.sub_steps.get_mut(sub_index) {
245 sub.status = StepStatus::Running;
246 }
247 }
248 }
249
250 pub fn complete_sub_step(&mut self, step_index: usize, sub_index: usize) {
252 if let Some(step) = self.steps.get_mut(step_index) {
253 if let Some(sub) = step.sub_steps.get_mut(sub_index) {
254 sub.status = StepStatus::Completed;
255 }
256 }
257 }
258
259 pub fn add_output(&mut self, step_index: usize, line: impl Into<String>) {
261 if let Some(step) = self.steps.get_mut(step_index) {
262 step.add_output(line);
263 }
264 }
265
266 pub fn toggle_expanded(&mut self, index: usize) {
268 if let Some(step) = self.steps.get_mut(index) {
269 step.expanded = !step.expanded;
270 }
271 }
272
273 pub fn scroll_output(&mut self, index: usize, delta: i32) {
275 if let Some(step) = self.steps.get_mut(index) {
276 let max_scroll = step.output.len().saturating_sub(5) as i32;
277 let new_scroll = (step.scroll as i32 + delta).clamp(0, max_scroll);
278 step.scroll = new_scroll as u16;
279 }
280 }
281}
282
283#[derive(Debug, Clone)]
285pub struct StepDisplayStyle {
286 pub focused_border: Color,
288 pub unfocused_border: Color,
290 pub max_output_lines: usize,
292}
293
294impl Default for StepDisplayStyle {
295 fn default() -> Self {
296 Self {
297 focused_border: Color::Cyan,
298 unfocused_border: Color::DarkGray,
299 max_output_lines: 5,
300 }
301 }
302}
303
304impl From<&crate::theme::Theme> for StepDisplayStyle {
305 fn from(theme: &crate::theme::Theme) -> Self {
306 let p = &theme.palette;
307 Self {
308 focused_border: p.border_accent,
309 unfocused_border: p.border_disabled,
310 max_output_lines: 5,
311 }
312 }
313}
314
315pub struct StepDisplay<'a> {
317 state: &'a StepDisplayState,
318 style: StepDisplayStyle,
319}
320
321impl<'a> StepDisplay<'a> {
322 pub fn new(state: &'a StepDisplayState) -> Self {
324 Self {
325 state,
326 style: StepDisplayStyle::default(),
327 }
328 }
329
330 pub fn style(mut self, style: StepDisplayStyle) -> Self {
332 self.style = style;
333 self
334 }
335
336 pub fn theme(self, theme: &crate::theme::Theme) -> Self {
338 self.style(StepDisplayStyle::from(theme))
339 }
340
341 fn build_lines(&self, area: Rect) -> Vec<Line<'static>> {
343 let mut lines = Vec::new();
344 let full_width = area.width as usize;
345
346 for (idx, step) in self.state.steps.iter().enumerate() {
347 let icon_color = step.status.color();
349 let step_style = match step.status {
350 StepStatus::Running => Style::default()
351 .fg(Color::Yellow)
352 .add_modifier(Modifier::BOLD),
353 StepStatus::Failed => Style::default().fg(Color::Red),
354 StepStatus::Completed => Style::default().fg(Color::Green),
355 _ => Style::default().fg(Color::White),
356 };
357
358 let header_suffix = if !step.sub_steps.is_empty() {
359 let (completed, total) = step.sub_step_progress();
360 format!(" ({}/{})", completed, total)
361 } else {
362 String::new()
363 };
364
365 lines.push(Line::from(vec![
366 Span::styled(
367 format!("{} ", step.status.icon()),
368 Style::default().fg(icon_color),
369 ),
370 Span::styled(format!("Step {}: ", idx + 1), step_style),
371 Span::styled(step.name.clone(), step_style),
372 Span::styled(header_suffix, Style::default().fg(Color::DarkGray)),
373 ]));
374
375 if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
377 for sub in &step.sub_steps {
378 let sub_color = sub.status.color();
379 let sub_style = match sub.status {
380 StepStatus::Running => Style::default().fg(Color::Yellow),
381 StepStatus::Completed => Style::default().fg(Color::Green),
382 StepStatus::Failed => Style::default().fg(Color::Red),
383 StepStatus::Skipped => Style::default().fg(Color::DarkGray),
384 _ => Style::default().fg(Color::White),
385 };
386
387 lines.push(Line::from(vec![
388 Span::raw(" "),
389 Span::styled(
390 format!("{} ", sub.status.sub_icon()),
391 Style::default().fg(sub_color),
392 ),
393 Span::styled(sub.name.clone(), sub_style),
394 ]));
395 }
396 }
397
398 if step.expanded && !step.output.is_empty() {
400 let is_focused = self.state.focused_step == Some(idx);
401 let border_color = if is_focused {
402 self.style.focused_border
403 } else {
404 self.style.unfocused_border
405 };
406
407 let border_width = full_width.saturating_sub(6);
408 let content_width = full_width.saturating_sub(8);
409
410 lines.push(Line::from(Span::styled(
412 format!(" ┌{:─<width$}┐ ", " Output ", width = border_width),
413 Style::default().fg(border_color),
414 )));
415
416 let visible_lines = self.style.max_output_lines;
418 let scroll = step.scroll as usize;
419 let total = step.output.len();
420
421 for i in 0..visible_lines {
422 let line_idx = scroll + i;
423 let content = if line_idx < total {
424 truncate_to_width(&step.output[line_idx], content_width)
425 } else {
426 String::new()
427 };
428
429 let padded = pad_to_width(&content, content_width);
430 lines.push(Line::from(vec![
431 Span::styled(" │ ", Style::default().fg(border_color)),
432 Span::styled(padded, Style::default().fg(Color::Gray)),
433 Span::styled(" │ ", Style::default().fg(border_color)),
434 ]));
435 }
436
437 let scroll_info = if total > visible_lines {
439 format!(" [{}/{} lines] ", scroll + visible_lines.min(total), total)
440 } else {
441 String::new()
442 };
443
444 lines.push(Line::from(Span::styled(
445 format!(" └{:─<width$}┘ ", scroll_info, width = border_width),
446 Style::default().fg(border_color),
447 )));
448
449 lines.push(Line::from(""));
451 }
452 }
453
454 lines
455 }
456}
457
458impl Widget for StepDisplay<'_> {
459 fn render(self, area: Rect, buf: &mut Buffer) {
460 let lines = self.build_lines(area);
461 let para = Paragraph::new(lines).scroll((self.state.scroll, 0));
462 para.render(area, buf);
463 }
464}
465
466pub fn calculate_height(state: &StepDisplayState, style: &StepDisplayStyle) -> u16 {
468 let mut height = 0u16;
469
470 for step in &state.steps {
471 height += 1; if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
475 height += step.sub_steps.len() as u16;
476 }
477
478 if step.expanded && !step.output.is_empty() {
480 height += 2; height += style.max_output_lines as u16;
482 height += 1; }
484 }
485
486 height
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 #[test]
494 fn test_step_status_icons() {
495 assert_eq!(StepStatus::Pending.icon(), "[ ]");
496 assert_eq!(StepStatus::Running.icon(), "[▶]");
497 assert_eq!(StepStatus::Completed.icon(), "[✓]");
498 assert_eq!(StepStatus::Failed.icon(), "[✗]");
499 assert_eq!(StepStatus::Skipped.icon(), "[↷]");
500 }
501
502 #[test]
503 fn test_step_status_sub_icons() {
504 assert_eq!(StepStatus::Pending.sub_icon(), "○");
505 assert_eq!(StepStatus::Running.sub_icon(), "◐");
506 assert_eq!(StepStatus::Completed.sub_icon(), "●");
507 assert_eq!(StepStatus::Failed.sub_icon(), "✗");
508 assert_eq!(StepStatus::Skipped.sub_icon(), "◌");
509 }
510
511 #[test]
512 fn test_step_status_colors() {
513 assert_eq!(StepStatus::Pending.color(), Color::DarkGray);
514 assert_eq!(StepStatus::Running.color(), Color::Yellow);
515 assert_eq!(StepStatus::Completed.color(), Color::Green);
516 assert_eq!(StepStatus::Failed.color(), Color::Red);
517 assert_eq!(StepStatus::Skipped.color(), Color::DarkGray);
518 }
519
520 #[test]
521 fn test_step_new() {
522 let step = Step::new("Build");
523 assert_eq!(step.name, "Build");
524 assert_eq!(step.status, StepStatus::Pending);
525 assert!(step.sub_steps.is_empty());
526 assert!(!step.expanded);
527 assert!(step.output.is_empty());
528 }
529
530 #[test]
531 fn test_step_with_sub_steps() {
532 let step = Step::new("Build").with_sub_steps(vec!["Compile", "Link", "Package"]);
533 assert_eq!(step.sub_steps.len(), 3);
534 assert_eq!(step.sub_steps[0].name, "Compile");
535 assert_eq!(step.sub_steps[1].name, "Link");
536 assert_eq!(step.sub_steps[2].name, "Package");
537 }
538
539 #[test]
540 fn test_step_progress() {
541 let step = Step::new("Test").with_sub_steps(vec!["A", "B", "C"]);
542 let (completed, total) = step.sub_step_progress();
543 assert_eq!(completed, 0);
544 assert_eq!(total, 3);
545 }
546
547 #[test]
548 fn test_step_add_output() {
549 let mut step = Step::new("Test");
550 step.add_output("Line 1");
551 step.add_output("Line 2");
552 assert_eq!(step.output.len(), 2);
553 assert_eq!(step.output[0], "Line 1");
554 }
555
556 #[test]
557 fn test_step_clear_output() {
558 let mut step = Step::new("Test");
559 step.add_output("Line 1");
560 step.add_output("Line 2");
561 step.scroll = 1;
562 step.clear_output();
563 assert!(step.output.is_empty());
564 assert_eq!(step.scroll, 0);
565 }
566
567 #[test]
568 fn test_step_auto_scroll() {
569 let mut step = Step::new("Test");
570 for i in 0..10 {
572 step.add_output(format!("Line {}", i));
573 }
574 assert!(step.scroll > 0);
576 }
577
578 #[test]
579 fn test_sub_step_new() {
580 let sub = SubStep::new("Compile");
581 assert_eq!(sub.name, "Compile");
582 assert_eq!(sub.status, StepStatus::Pending);
583 }
584
585 #[test]
586 fn test_state_new() {
587 let steps = vec![Step::new("Step 1"), Step::new("Step 2")];
588 let state = StepDisplayState::new(steps);
589 assert_eq!(state.steps.len(), 2);
590 assert!(state.focused_step.is_none());
591 assert_eq!(state.scroll, 0);
592 }
593
594 #[test]
595 fn test_state_progress() {
596 let steps = vec![
597 Step::new("Step 1"),
598 Step::new("Step 2"),
599 Step::new("Step 3"),
600 Step::new("Step 4"),
601 ];
602 let mut state = StepDisplayState::new(steps);
603
604 assert_eq!(state.progress(), 0.0);
605
606 state.complete_step(0);
607 assert!((state.progress() - 0.25).abs() < 0.01);
608
609 state.complete_step(1);
610 assert!((state.progress() - 0.5).abs() < 0.01);
611 }
612
613 #[test]
614 fn test_state_progress_empty() {
615 let state = StepDisplayState::new(vec![]);
616 assert_eq!(state.progress(), 0.0);
617 }
618
619 #[test]
620 fn test_state_current_step() {
621 let steps = vec![
622 Step::new("Step 1"),
623 Step::new("Step 2"),
624 Step::new("Step 3"),
625 ];
626 let mut state = StepDisplayState::new(steps);
627
628 assert_eq!(state.current_step(), 0);
629
630 state.complete_step(0);
631 assert_eq!(state.current_step(), 1);
632
633 state.skip_step(1);
634 assert_eq!(state.current_step(), 2);
635 }
636
637 #[test]
638 fn test_state_operations() {
639 let steps = vec![Step::new("Test")];
640 let mut state = StepDisplayState::new(steps);
641
642 state.start_step(0);
643 assert_eq!(state.steps[0].status, StepStatus::Running);
644 assert!(state.steps[0].expanded);
645
646 state.add_output(0, "Line 1");
647 assert_eq!(state.steps[0].output.len(), 1);
648
649 state.complete_step(0);
650 assert_eq!(state.steps[0].status, StepStatus::Completed);
651 }
652
653 #[test]
654 fn test_state_fail_step() {
655 let steps = vec![Step::new("Test")];
656 let mut state = StepDisplayState::new(steps);
657
658 state.fail_step(0);
659 assert_eq!(state.steps[0].status, StepStatus::Failed);
660 }
661
662 #[test]
663 fn test_state_skip_step() {
664 let steps = vec![Step::new("Test")];
665 let mut state = StepDisplayState::new(steps);
666
667 state.skip_step(0);
668 assert_eq!(state.steps[0].status, StepStatus::Skipped);
669 }
670
671 #[test]
672 fn test_state_sub_step_operations() {
673 let steps = vec![Step::new("Test").with_sub_steps(vec!["A", "B"])];
674 let mut state = StepDisplayState::new(steps);
675
676 state.start_sub_step(0, 0);
677 assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Running);
678
679 state.complete_sub_step(0, 0);
680 assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Completed);
681 }
682
683 #[test]
684 fn test_state_toggle_expanded() {
685 let steps = vec![Step::new("Test")];
686 let mut state = StepDisplayState::new(steps);
687
688 assert!(!state.steps[0].expanded);
689 state.toggle_expanded(0);
690 assert!(state.steps[0].expanded);
691 state.toggle_expanded(0);
692 assert!(!state.steps[0].expanded);
693 }
694
695 #[test]
696 fn test_state_scroll_output() {
697 let mut step = Step::new("Test");
698 for i in 0..20 {
699 step.add_output(format!("Line {}", i));
700 }
701 let steps = vec![step];
702 let mut state = StepDisplayState::new(steps);
703 state.steps[0].scroll = 0;
704
705 state.scroll_output(0, 5);
706 assert_eq!(state.steps[0].scroll, 5);
707
708 state.scroll_output(0, -3);
709 assert_eq!(state.steps[0].scroll, 2);
710
711 state.scroll_output(0, -10);
713 assert_eq!(state.steps[0].scroll, 0);
714 }
715
716 #[test]
717 fn test_state_invalid_index() {
718 let steps = vec![Step::new("Test")];
719 let mut state = StepDisplayState::new(steps);
720
721 state.start_step(10);
723 state.complete_step(10);
724 state.fail_step(10);
725 state.skip_step(10);
726 state.add_output(10, "test");
727 state.toggle_expanded(10);
728 state.scroll_output(10, 5);
729 state.start_sub_step(10, 0);
730 state.complete_sub_step(10, 0);
731 }
732
733 #[test]
734 fn test_step_display_style_default() {
735 let style = StepDisplayStyle::default();
736 assert_eq!(style.focused_border, Color::Cyan);
737 assert_eq!(style.unfocused_border, Color::DarkGray);
738 assert_eq!(style.max_output_lines, 5);
739 }
740
741 #[test]
742 fn test_calculate_height() {
743 let steps = vec![
744 Step::new("Step 1"),
745 Step::new("Step 2").with_sub_steps(vec!["A", "B"]),
746 ];
747 let mut state = StepDisplayState::new(steps);
748 let style = StepDisplayStyle::default();
749
750 let height = calculate_height(&state, &style);
752 assert_eq!(height, 2);
753
754 state.start_step(1);
756 let height = calculate_height(&state, &style);
757 assert!(height > 2); }
759
760 #[test]
761 fn test_step_display_render() {
762 let steps = vec![
763 Step::new("Build").with_sub_steps(vec!["Compile", "Link"]),
764 Step::new("Test"),
765 ];
766 let state = StepDisplayState::new(steps);
767 let display = StepDisplay::new(&state);
768
769 let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
770 display.render(Rect::new(0, 0, 60, 20), &mut buf);
771 }
773}