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
304pub struct StepDisplay<'a> {
306 state: &'a StepDisplayState,
307 style: StepDisplayStyle,
308}
309
310impl<'a> StepDisplay<'a> {
311 pub fn new(state: &'a StepDisplayState) -> Self {
313 Self {
314 state,
315 style: StepDisplayStyle::default(),
316 }
317 }
318
319 pub fn style(mut self, style: StepDisplayStyle) -> Self {
321 self.style = style;
322 self
323 }
324
325 fn build_lines(&self, area: Rect) -> Vec<Line<'static>> {
327 let mut lines = Vec::new();
328 let full_width = area.width as usize;
329
330 for (idx, step) in self.state.steps.iter().enumerate() {
331 let icon_color = step.status.color();
333 let step_style = match step.status {
334 StepStatus::Running => Style::default()
335 .fg(Color::Yellow)
336 .add_modifier(Modifier::BOLD),
337 StepStatus::Failed => Style::default().fg(Color::Red),
338 StepStatus::Completed => Style::default().fg(Color::Green),
339 _ => Style::default().fg(Color::White),
340 };
341
342 let header_suffix = if !step.sub_steps.is_empty() {
343 let (completed, total) = step.sub_step_progress();
344 format!(" ({}/{})", completed, total)
345 } else {
346 String::new()
347 };
348
349 lines.push(Line::from(vec![
350 Span::styled(
351 format!("{} ", step.status.icon()),
352 Style::default().fg(icon_color),
353 ),
354 Span::styled(format!("Step {}: ", idx + 1), step_style),
355 Span::styled(step.name.clone(), step_style),
356 Span::styled(header_suffix, Style::default().fg(Color::DarkGray)),
357 ]));
358
359 if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
361 for sub in &step.sub_steps {
362 let sub_color = sub.status.color();
363 let sub_style = match sub.status {
364 StepStatus::Running => Style::default().fg(Color::Yellow),
365 StepStatus::Completed => Style::default().fg(Color::Green),
366 StepStatus::Failed => Style::default().fg(Color::Red),
367 StepStatus::Skipped => Style::default().fg(Color::DarkGray),
368 _ => Style::default().fg(Color::White),
369 };
370
371 lines.push(Line::from(vec![
372 Span::raw(" "),
373 Span::styled(
374 format!("{} ", sub.status.sub_icon()),
375 Style::default().fg(sub_color),
376 ),
377 Span::styled(sub.name.clone(), sub_style),
378 ]));
379 }
380 }
381
382 if step.expanded && !step.output.is_empty() {
384 let is_focused = self.state.focused_step == Some(idx);
385 let border_color = if is_focused {
386 self.style.focused_border
387 } else {
388 self.style.unfocused_border
389 };
390
391 let border_width = full_width.saturating_sub(6);
392 let content_width = full_width.saturating_sub(8);
393
394 lines.push(Line::from(Span::styled(
396 format!(" ┌{:─<width$}┐ ", " Output ", width = border_width),
397 Style::default().fg(border_color),
398 )));
399
400 let visible_lines = self.style.max_output_lines;
402 let scroll = step.scroll as usize;
403 let total = step.output.len();
404
405 for i in 0..visible_lines {
406 let line_idx = scroll + i;
407 let content = if line_idx < total {
408 truncate_to_width(&step.output[line_idx], content_width)
409 } else {
410 String::new()
411 };
412
413 let padded = pad_to_width(&content, content_width);
414 lines.push(Line::from(vec![
415 Span::styled(" │ ", Style::default().fg(border_color)),
416 Span::styled(padded, Style::default().fg(Color::Gray)),
417 Span::styled(" │ ", Style::default().fg(border_color)),
418 ]));
419 }
420
421 let scroll_info = if total > visible_lines {
423 format!(" [{}/{} lines] ", scroll + visible_lines.min(total), total)
424 } else {
425 String::new()
426 };
427
428 lines.push(Line::from(Span::styled(
429 format!(" └{:─<width$}┘ ", scroll_info, width = border_width),
430 Style::default().fg(border_color),
431 )));
432
433 lines.push(Line::from(""));
435 }
436 }
437
438 lines
439 }
440}
441
442impl Widget for StepDisplay<'_> {
443 fn render(self, area: Rect, buf: &mut Buffer) {
444 let lines = self.build_lines(area);
445 let para = Paragraph::new(lines).scroll((self.state.scroll, 0));
446 para.render(area, buf);
447 }
448}
449
450pub fn calculate_height(state: &StepDisplayState, style: &StepDisplayStyle) -> u16 {
452 let mut height = 0u16;
453
454 for step in &state.steps {
455 height += 1; if !step.sub_steps.is_empty() && (step.expanded || step.status == StepStatus::Running) {
459 height += step.sub_steps.len() as u16;
460 }
461
462 if step.expanded && !step.output.is_empty() {
464 height += 2; height += style.max_output_lines as u16;
466 height += 1; }
468 }
469
470 height
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_step_status_icons() {
479 assert_eq!(StepStatus::Pending.icon(), "[ ]");
480 assert_eq!(StepStatus::Running.icon(), "[▶]");
481 assert_eq!(StepStatus::Completed.icon(), "[✓]");
482 assert_eq!(StepStatus::Failed.icon(), "[✗]");
483 assert_eq!(StepStatus::Skipped.icon(), "[↷]");
484 }
485
486 #[test]
487 fn test_step_status_sub_icons() {
488 assert_eq!(StepStatus::Pending.sub_icon(), "○");
489 assert_eq!(StepStatus::Running.sub_icon(), "◐");
490 assert_eq!(StepStatus::Completed.sub_icon(), "●");
491 assert_eq!(StepStatus::Failed.sub_icon(), "✗");
492 assert_eq!(StepStatus::Skipped.sub_icon(), "◌");
493 }
494
495 #[test]
496 fn test_step_status_colors() {
497 assert_eq!(StepStatus::Pending.color(), Color::DarkGray);
498 assert_eq!(StepStatus::Running.color(), Color::Yellow);
499 assert_eq!(StepStatus::Completed.color(), Color::Green);
500 assert_eq!(StepStatus::Failed.color(), Color::Red);
501 assert_eq!(StepStatus::Skipped.color(), Color::DarkGray);
502 }
503
504 #[test]
505 fn test_step_new() {
506 let step = Step::new("Build");
507 assert_eq!(step.name, "Build");
508 assert_eq!(step.status, StepStatus::Pending);
509 assert!(step.sub_steps.is_empty());
510 assert!(!step.expanded);
511 assert!(step.output.is_empty());
512 }
513
514 #[test]
515 fn test_step_with_sub_steps() {
516 let step = Step::new("Build").with_sub_steps(vec!["Compile", "Link", "Package"]);
517 assert_eq!(step.sub_steps.len(), 3);
518 assert_eq!(step.sub_steps[0].name, "Compile");
519 assert_eq!(step.sub_steps[1].name, "Link");
520 assert_eq!(step.sub_steps[2].name, "Package");
521 }
522
523 #[test]
524 fn test_step_progress() {
525 let step = Step::new("Test").with_sub_steps(vec!["A", "B", "C"]);
526 let (completed, total) = step.sub_step_progress();
527 assert_eq!(completed, 0);
528 assert_eq!(total, 3);
529 }
530
531 #[test]
532 fn test_step_add_output() {
533 let mut step = Step::new("Test");
534 step.add_output("Line 1");
535 step.add_output("Line 2");
536 assert_eq!(step.output.len(), 2);
537 assert_eq!(step.output[0], "Line 1");
538 }
539
540 #[test]
541 fn test_step_clear_output() {
542 let mut step = Step::new("Test");
543 step.add_output("Line 1");
544 step.add_output("Line 2");
545 step.scroll = 1;
546 step.clear_output();
547 assert!(step.output.is_empty());
548 assert_eq!(step.scroll, 0);
549 }
550
551 #[test]
552 fn test_step_auto_scroll() {
553 let mut step = Step::new("Test");
554 for i in 0..10 {
556 step.add_output(format!("Line {}", i));
557 }
558 assert!(step.scroll > 0);
560 }
561
562 #[test]
563 fn test_sub_step_new() {
564 let sub = SubStep::new("Compile");
565 assert_eq!(sub.name, "Compile");
566 assert_eq!(sub.status, StepStatus::Pending);
567 }
568
569 #[test]
570 fn test_state_new() {
571 let steps = vec![Step::new("Step 1"), Step::new("Step 2")];
572 let state = StepDisplayState::new(steps);
573 assert_eq!(state.steps.len(), 2);
574 assert!(state.focused_step.is_none());
575 assert_eq!(state.scroll, 0);
576 }
577
578 #[test]
579 fn test_state_progress() {
580 let steps = vec![
581 Step::new("Step 1"),
582 Step::new("Step 2"),
583 Step::new("Step 3"),
584 Step::new("Step 4"),
585 ];
586 let mut state = StepDisplayState::new(steps);
587
588 assert_eq!(state.progress(), 0.0);
589
590 state.complete_step(0);
591 assert!((state.progress() - 0.25).abs() < 0.01);
592
593 state.complete_step(1);
594 assert!((state.progress() - 0.5).abs() < 0.01);
595 }
596
597 #[test]
598 fn test_state_progress_empty() {
599 let state = StepDisplayState::new(vec![]);
600 assert_eq!(state.progress(), 0.0);
601 }
602
603 #[test]
604 fn test_state_current_step() {
605 let steps = vec![
606 Step::new("Step 1"),
607 Step::new("Step 2"),
608 Step::new("Step 3"),
609 ];
610 let mut state = StepDisplayState::new(steps);
611
612 assert_eq!(state.current_step(), 0);
613
614 state.complete_step(0);
615 assert_eq!(state.current_step(), 1);
616
617 state.skip_step(1);
618 assert_eq!(state.current_step(), 2);
619 }
620
621 #[test]
622 fn test_state_operations() {
623 let steps = vec![Step::new("Test")];
624 let mut state = StepDisplayState::new(steps);
625
626 state.start_step(0);
627 assert_eq!(state.steps[0].status, StepStatus::Running);
628 assert!(state.steps[0].expanded);
629
630 state.add_output(0, "Line 1");
631 assert_eq!(state.steps[0].output.len(), 1);
632
633 state.complete_step(0);
634 assert_eq!(state.steps[0].status, StepStatus::Completed);
635 }
636
637 #[test]
638 fn test_state_fail_step() {
639 let steps = vec![Step::new("Test")];
640 let mut state = StepDisplayState::new(steps);
641
642 state.fail_step(0);
643 assert_eq!(state.steps[0].status, StepStatus::Failed);
644 }
645
646 #[test]
647 fn test_state_skip_step() {
648 let steps = vec![Step::new("Test")];
649 let mut state = StepDisplayState::new(steps);
650
651 state.skip_step(0);
652 assert_eq!(state.steps[0].status, StepStatus::Skipped);
653 }
654
655 #[test]
656 fn test_state_sub_step_operations() {
657 let steps = vec![Step::new("Test").with_sub_steps(vec!["A", "B"])];
658 let mut state = StepDisplayState::new(steps);
659
660 state.start_sub_step(0, 0);
661 assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Running);
662
663 state.complete_sub_step(0, 0);
664 assert_eq!(state.steps[0].sub_steps[0].status, StepStatus::Completed);
665 }
666
667 #[test]
668 fn test_state_toggle_expanded() {
669 let steps = vec![Step::new("Test")];
670 let mut state = StepDisplayState::new(steps);
671
672 assert!(!state.steps[0].expanded);
673 state.toggle_expanded(0);
674 assert!(state.steps[0].expanded);
675 state.toggle_expanded(0);
676 assert!(!state.steps[0].expanded);
677 }
678
679 #[test]
680 fn test_state_scroll_output() {
681 let mut step = Step::new("Test");
682 for i in 0..20 {
683 step.add_output(format!("Line {}", i));
684 }
685 let steps = vec![step];
686 let mut state = StepDisplayState::new(steps);
687 state.steps[0].scroll = 0;
688
689 state.scroll_output(0, 5);
690 assert_eq!(state.steps[0].scroll, 5);
691
692 state.scroll_output(0, -3);
693 assert_eq!(state.steps[0].scroll, 2);
694
695 state.scroll_output(0, -10);
697 assert_eq!(state.steps[0].scroll, 0);
698 }
699
700 #[test]
701 fn test_state_invalid_index() {
702 let steps = vec![Step::new("Test")];
703 let mut state = StepDisplayState::new(steps);
704
705 state.start_step(10);
707 state.complete_step(10);
708 state.fail_step(10);
709 state.skip_step(10);
710 state.add_output(10, "test");
711 state.toggle_expanded(10);
712 state.scroll_output(10, 5);
713 state.start_sub_step(10, 0);
714 state.complete_sub_step(10, 0);
715 }
716
717 #[test]
718 fn test_step_display_style_default() {
719 let style = StepDisplayStyle::default();
720 assert_eq!(style.focused_border, Color::Cyan);
721 assert_eq!(style.unfocused_border, Color::DarkGray);
722 assert_eq!(style.max_output_lines, 5);
723 }
724
725 #[test]
726 fn test_calculate_height() {
727 let steps = vec![
728 Step::new("Step 1"),
729 Step::new("Step 2").with_sub_steps(vec!["A", "B"]),
730 ];
731 let mut state = StepDisplayState::new(steps);
732 let style = StepDisplayStyle::default();
733
734 let height = calculate_height(&state, &style);
736 assert_eq!(height, 2);
737
738 state.start_step(1);
740 let height = calculate_height(&state, &style);
741 assert!(height > 2); }
743
744 #[test]
745 fn test_step_display_render() {
746 let steps = vec![
747 Step::new("Build").with_sub_steps(vec!["Compile", "Link"]),
748 Step::new("Test"),
749 ];
750 let state = StepDisplayState::new(steps);
751 let display = StepDisplay::new(&state);
752
753 let mut buf = Buffer::empty(Rect::new(0, 0, 60, 20));
754 display.render(Rect::new(0, 0, 60, 20), &mut buf);
755 }
757}